feat: Implement comprehensive shop and branch management, introduce POS and staff management APIs, and update documentation.

This commit is contained in:
Ho Ngoc Hai
2026-01-17 17:04:59 +07:00
parent 90e96b57ac
commit d63afc1790
21 changed files with 3953 additions and 10 deletions

View File

@@ -0,0 +1,338 @@
---
name: mermaid-diagrams
description: Mermaid diagram patterns cho documentation. Use for flowcharts, sequence diagrams, class diagrams, ER diagrams, system architecture, hoặc khi cần visualize processes và relationships trong markdown.
compatibility: "Mermaid JS, GitHub Markdown, VS Code"
metadata:
author: Velik Ho
version: "1.0"
references: "mermaid.js.org"
---
# Mermaid Diagrams / Sơ đồ Mermaid
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Visualizing processes, workflows, decision trees / Tạo sơ đồ quy trình, cây quyết định
- Documenting API flows, service interactions / Tài liệu API flows, tương tác giữa services
- Designing database schemas / Thiết kế database schema
- Creating system architecture diagrams / Tạo sơ đồ kiến trúc hệ thống
- Project timelines and planning / Timeline dự án
## Quick Reference / Tham Chiếu Nhanh
| Loại Sơ đồ | Sử dụng cho | Keyword |
|------------|-------------|---------|
| **Flowchart** | Quy trình, cây quyết định | `flowchart TD/LR` |
| **Sequence** | API flows, request/response | `sequenceDiagram` |
| **Class** | Code structure, patterns | `classDiagram` |
| **Graph** | System architecture | `graph TD/LR` |
| **ER** | Database schema | `erDiagram` |
| **Gantt** | Timeline, scheduling | `gantt` |
| **C4** | System context | `C4Context` |
---
## Core Patterns / Mẫu Chính
### 1. Flowchart
```mermaid
flowchart TD
Start([Start]) --> Input[Get Input]
Input --> Check{Valid?}
Check -->|Yes| Process[Process Data]
Check -->|No| Error[Show Error]
Process --> Output[Return Result]
Output --> End([End])
Error --> End
style Start fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:3px
style End fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:3px
style Check fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style Error fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
style Process fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
```
**Subgraphs for grouping:**
```mermaid
flowchart LR
A[Request] --> B{Auth?}
B -->|No| C[401]
B -->|Yes| D[Process]
subgraph Processing["Request Processing"]
D --> E[Validate]
E --> F[Execute]
end
F --> G[200 OK]
```
### 2. Sequence Diagram
```mermaid
sequenceDiagram
participant Client
participant API
participant Service
participant DB
Client->>API: POST /login
API->>Service: authenticate(credentials)
Service->>DB: findUser(email)
DB-->>Service: user
Service-->>API: JWT token
API-->>Client: 200 OK {token}
```
**With Alt/Opt blocks:**
```mermaid
sequenceDiagram
Client->>API: GET /users/:id
API->>Cache: get(key)
alt Cache Hit
Cache-->>API: cached data
else Cache Miss
API->>DB: SELECT * FROM users
DB-->>API: user data
API->>Cache: set(key, data, ttl)
end
API-->>Client: 200 OK
```
### 3. Class Diagram
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class BaseRepository {
#prisma: PrismaClient
+findById(id: string) T
+create(data: CreateDto) T
+delete(id: string) void
}
class UserRepository {
+findByEmail(email: string) User
}
BaseRepository <|-- UserRepository
```
### 4. ER Diagram
```mermaid
%%{init: {'theme':'dark'}}%%
erDiagram
User ||--o{ Session : has
User ||--o{ UserRole : has
Role ||--o{ UserRole : has
User {
string id PK
string email UK
string passwordHash
datetime createdAt
}
Session {
string id PK
string userId FK
string token UK
datetime expiresAt
}
```
### 5. System Architecture (Graph)
```mermaid
graph TD
Client[Web Client] --> Gateway[Traefik]
Gateway --> Auth[Auth Service]
Gateway --> User[User Service]
Auth --> DB[(PostgreSQL)]
User --> DB
Auth --> Cache[(Redis)]
style Gateway fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
style DB fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
```
---
## Dark Color Palette / Bảng Màu Tối
**LUÔN sử dụng bảng màu tối cho consistency:**
| Màu | Hex | Sử dụng cho | Border |
|-----|-----|-------------|--------|
| **Primary** | `#2C3E50` | Start, main nodes | `#34495E` |
| **Data** | `#34495E` | Database, Cache | `#2C3E50` |
| **Success** | `#27AE60` | End, confirmation | `#229954` |
| **Warning** | `#E67E22` | Decision nodes | `#D35400` |
| **Error** | `#C0392B` | Error, failure | `#A93226` |
| **Process** | `#8E44AD` | Processing steps | `#7D3C98` |
| **Info** | `#3498DB` | Gateway, API | `#2980B9` |
| **Neutral** | `#7F8C8D` | Neutral nodes | `#5D6D7E` |
**Màu chữ:** `#ECF0F1` (light text for contrast)
### Style Templates
```markdown
<!-- Start/End -->
style Start fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:3px
style End fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:3px
<!-- Decision -->
style Decision fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
<!-- Error -->
style Error fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
<!-- Processing -->
style Process fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
<!-- Database/Cache -->
style DB fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
<!-- Gateway/API -->
style Gateway fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
```
---
## Common Mistakes / Lỗi Thường Gặp
### 1. Arrow Syntax
```markdown
❌ SAI: A -> B (single dash)
✅ ĐÚNG: A --> B (double dash)
```
### 2. Missing Line Break
```markdown
❌ SAI: flowchart TD A --> B
✅ ĐÚNG:
flowchart TD
A --> B
```
### 3. Duplicate Node IDs
```markdown
❌ SAI:
graph TD
A[Start]
A[Process] <!-- Trùng ID -->
✅ ĐÚNG:
graph TD
A[Start]
B[Process]
```
### 4. Style Syntax
```markdown
❌ SAI: style Node fill:#2C3E50 <!-- Thiếu dấu phẩy -->
✅ ĐÚNG: style Node fill:#2C3E50,color:#ECF0F1
```
### 5. Subgraph Format
```markdown
❌ SAI: subgraph "My Group"
✅ ĐÚNG: subgraph MyGroup["My Group"]
```
### 6. Special Characters in Labels
```markdown
❌ SAI: A[Label (with parens)] <!-- Có thể gây lỗi -->
✅ ĐÚNG: A["Label (with parens)"] <!-- Quote labels có ký tự đặc biệt -->
```
---
## Visual Indicators / Emoji Indicators
| Emoji | Meaning | Use in |
|-------|---------|--------|
| 🚀 | Start/Launch | Start nodes |
| ✅ | Success | Success/End nodes |
| ❌ | Error/Failed | Error nodes |
| ⚠️ | Warning | Decision nodes |
| 🔐 | Security/Auth | Auth steps |
| 💾 | Database | Data nodes |
| ⚙️ | Processing | Process nodes |
| 🌐 | API/Network | External calls |
**Example:**
```mermaid
flowchart TD
Start(["🚀 Bắt đầu"]) --> Auth{"🔐 Xác thực?"}
Auth -->|"Có"| Process["⚙️ Xử lý"]
Auth -->|"Không"| Error["❌ Lỗi"]
Process --> DB[("💾 Database")]
DB --> Success["✅ Thành công"]
```
---
## Diagram Selection Matrix / Ma Trận Chọn Sơ đồ
| Mục đích | Loại sơ đồ |
|----------|------------|
| Quy trình, workflow | **Flowchart** |
| API calls, request/response | **Sequence** |
| Code structure, OOP | **Class** |
| System architecture | **Graph** |
| Database schema | **ER Diagram** |
| Project timeline | **Gantt** |
| High-level context | **C4** |
---
## Testing Diagrams / Kiểm Tra
```bash
# Install mermaid-cli
npm install -g @mermaid-js/mermaid-cli
# Test render (SVG)
mmdc -i your-doc.md -o test.svg
# Render PNG với dark theme
mmdc -i your-doc.md -o test.png -b black -t dark -s 3
```
---
## Troubleshooting Checklist
- [ ] Có xuống dòng sau `flowchart TD`?
- [ ] Arrow sử dụng `-->` không phải `->`?
- [ ] Không có Node ID trùng lặp?
- [ ] Style có dấu phẩy giữa các thuộc tính?
- [ ] Subgraph format: `subgraph ID["Label"]`?
- [ ] Hex color có dấu `#`?
- [ ] Labels đặc biệt được quote?
---
## Resources / Tài Nguyên
- [Mermaid Official Documentation](https://mermaid.js.org/)
- [Mermaid Live Editor](https://mermaid.live/)
- [Mermaid CheatSheet](https://jojozhuang.github.io/tutorial/mermaid-cheat-sheet/)
- [Documentation Skill](../documentation/SKILL.md) - Documentation guidelines

View File

@@ -116,13 +116,29 @@ dotnet ef database update \
| `POST` | `/api/v1/pos/devices/register` | Register POS device | ✅ Staff |
| `GET` | `/api/v1/pos/me` | Get current Staff info | ✅ Staff |
### Admin Endpoints
### Admin Merchant Endpoints (`/api/v1/admin/merchants`)
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `GET` | `/api/v1/admin/merchants` | List all merchants | ✅ Admin |
| `GET` | `/api/v1/admin/merchants` | List all merchants (paginated) | ✅ Admin |
| `GET` | `/api/v1/admin/merchants/statistics` | Merchant statistics | ✅ Admin |
| `GET` | `/api/v1/admin/merchants/{id}` | Merchant details | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/approve` | Approve merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/reject` | Reject merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/suspend` | Suspend merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/reactivate` | Reactivate merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/ban` | Permanently ban merchant | ✅ Admin |
### Admin Shop Endpoints (`/api/v1/admin/shops`)
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `GET` | `/api/v1/admin/shops` | List all shops (paginated) | ✅ Admin |
| `GET` | `/api/v1/admin/shops/{id}` | Shop details | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/suspend` | Suspend shop | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/reactivate` | Reactivate shop | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/close` | Permanently close shop | ✅ Admin |
### Health Checks

View File

@@ -116,13 +116,29 @@ dotnet ef database update \
| `POST` | `/api/v1/pos/devices/register` | Đăng ký thiết bị POS | ✅ Staff |
| `GET` | `/api/v1/pos/me` | Thông tin Staff hiện tại | ✅ Staff |
### Admin Endpoints
### Admin Merchant Endpoints (`/api/v1/admin/merchants`)
| Method | Endpoint | Mô Tả | Auth |
|--------|----------|-------|------|
| `GET` | `/api/v1/admin/merchants` | Danh sách tất cả Merchants | ✅ Admin |
| `GET` | `/api/v1/admin/merchants` | Danh sách tất cả Merchants (phân trang) | ✅ Admin |
| `GET` | `/api/v1/admin/merchants/statistics` | Thống kê Merchants | ✅ Admin |
| `GET` | `/api/v1/admin/merchants/{id}` | Chi tiết Merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/approve` | Phê duyệt Merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/reject` | Từ chối Merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/suspend` | Tạm ngưng Merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/reactivate` | Kích hoạt lại Merchant | ✅ Admin |
| `POST` | `/api/v1/admin/merchants/{id}/ban` | Cấm vĩnh viễn Merchant | ✅ Admin |
### Admin Shop Endpoints (`/api/v1/admin/shops`)
| Method | Endpoint | Mô Tả | Auth |
|--------|----------|-------|------|
| `GET` | `/api/v1/admin/shops` | Danh sách tất cả Shops (phân trang) | ✅ Admin |
| `GET` | `/api/v1/admin/shops/{id}` | Chi tiết Shop | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/suspend` | Tạm ngưng Shop | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/reactivate` | Kích hoạt lại Shop | ✅ Admin |
| `POST` | `/api/v1/admin/shops/{id}/close` | Đóng Shop vĩnh viễn | ✅ Admin |
### Health Checks

View File

@@ -0,0 +1,148 @@
// EN: POS commands for PIN auth and device registration.
// VI: Commands POS cho xác thực PIN và đăng ký thiết bị.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Pos;
#region PIN Auth Command
/// <summary>
/// EN: Command to authenticate staff using PIN for POS.
/// VI: Command để xác thực nhân viên sử dụng PIN cho POS.
/// </summary>
public record PinAuthCommand : IRequest<PinAuthResult>
{
public Guid StaffId { get; init; }
public string PinCode { get; init; } = null!;
public string? DeviceId { get; init; }
}
public record PinAuthResult(
bool IsAuthenticated,
Guid? StaffId,
string? StaffEmail,
string? Role,
Guid? ShopId,
string? ErrorMessage);
/// <summary>
/// EN: Handler for PinAuthCommand.
/// VI: Handler cho PinAuthCommand.
/// </summary>
public class PinAuthCommandHandler : IRequestHandler<PinAuthCommand, PinAuthResult>
{
private readonly IMerchantStaffRepository _staffRepository;
private readonly ILogger<PinAuthCommandHandler> _logger;
public PinAuthCommandHandler(
IMerchantStaffRepository staffRepository,
ILogger<PinAuthCommandHandler> logger)
{
_staffRepository = staffRepository;
_logger = logger;
}
public async Task<PinAuthResult> Handle(PinAuthCommand request, CancellationToken cancellationToken)
{
var staff = await _staffRepository.GetByIdAsync(request.StaffId, cancellationToken);
if (staff == null)
{
return new PinAuthResult(false, null, null, null, null, "Staff not found");
}
if (staff.Status != StaffStatus.Active)
{
return new PinAuthResult(false, null, null, null, null, "Staff is not active");
}
if (!staff.VerifyPinCode(request.PinCode))
{
_logger.LogWarning("PIN verification failed for staff {StaffId}", request.StaffId);
return new PinAuthResult(false, null, null, null, null, "Invalid PIN");
}
// EN: Get first shop assignment if available
// VI: Lấy shop đầu tiên nếu có
var shopId = staff.ShopAssignments.FirstOrDefault()?.ShopId;
_logger.LogInformation("POS authentication successful for staff {StaffId}", staff.Id);
return new PinAuthResult(
true,
staff.Id,
staff.Email,
staff.Role.Name,
shopId,
null);
}
}
#endregion
#region Register Device Command
/// <summary>
/// EN: Command to register a POS device.
/// VI: Command để đăng ký thiết bị POS.
/// </summary>
public record RegisterDeviceCommand : IRequest<RegisterDeviceResult>
{
public string DeviceId { get; init; } = null!;
public string DeviceName { get; init; } = null!;
public string Platform { get; init; } = null!;
public string? PushToken { get; init; }
}
public record RegisterDeviceResult(Guid StaffId, string DeviceId, bool IsRegistered);
/// <summary>
/// EN: Handler for RegisterDeviceCommand.
/// VI: Handler cho RegisterDeviceCommand.
/// </summary>
public class RegisterDeviceCommandHandler : IRequestHandler<RegisterDeviceCommand, RegisterDeviceResult>
{
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<RegisterDeviceCommandHandler> _logger;
public RegisterDeviceCommandHandler(
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<RegisterDeviceCommandHandler> logger)
{
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<RegisterDeviceResult> Handle(RegisterDeviceCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var staff = await _staffRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Staff not found");
staff.RegisterDevice(request.DeviceId, request.DeviceName, request.PushToken, request.Platform);
_staffRepository.Update(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Device registered: {DeviceId} for staff {StaffId}", request.DeviceId, staff.Id);
return new RegisterDeviceResult(staff.Id, request.DeviceId, true);
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion

View File

@@ -0,0 +1,179 @@
// EN: Commands for branch management - Update and Delete.
// VI: Commands cho quản lý chi nhánh - Cập nhật và Xóa.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Shops;
#region Update Branch Command
/// <summary>
/// EN: Command to update an existing branch.
/// VI: Command để cập nhật chi nhánh.
/// </summary>
public record UpdateBranchCommand : IRequest<bool>
{
public Guid ShopId { get; init; }
public Guid BranchId { get; init; }
public string Name { get; init; } = null!;
public string? Code { get; init; }
public string Street { get; init; } = null!;
public string? Ward { get; init; }
public string District { get; init; } = null!;
public string City { get; init; } = null!;
public string? Province { get; init; }
public double? Latitude { get; init; }
public double? Longitude { get; init; }
public string? Phone { get; init; }
}
/// <summary>
/// EN: Handler for UpdateBranchCommand.
/// VI: Handler cho UpdateBranchCommand.
/// </summary>
public class UpdateBranchCommandHandler : IRequestHandler<UpdateBranchCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<UpdateBranchCommandHandler> _logger;
public UpdateBranchCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<UpdateBranchCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> Handle(UpdateBranchCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken)
?? throw new DomainException("Shop not found");
if (shop.MerchantId != merchant.Id)
throw new DomainException("You do not have permission to update this shop");
var branch = shop.Branches.FirstOrDefault(b => b.Id == request.BranchId)
?? throw new DomainException("Branch not found");
// EN: Build address using record with init
// VI: Xây dựng địa chỉ sử dụng record với init
var address = new Address
{
Street = request.Street,
Ward = request.Ward,
District = request.District,
City = request.City,
Province = request.Province
};
GeoLocation? location = null;
if (request.Latitude.HasValue && request.Longitude.HasValue)
{
location = new GeoLocation
{
Latitude = request.Latitude.Value,
Longitude = request.Longitude.Value
};
}
// EN: Update branch
// VI: Cập nhật chi nhánh
branch.Update(request.Name, request.Code, address, location);
if (!string.IsNullOrWhiteSpace(request.Phone))
branch.SetPhone(request.Phone);
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Branch {BranchId} updated in Shop {ShopId}", request.BranchId, request.ShopId);
return true;
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion
#region Delete Branch Command
/// <summary>
/// EN: Command to delete a branch.
/// VI: Command để xóa chi nhánh.
/// </summary>
public record DeleteBranchCommand(Guid ShopId, Guid BranchId) : IRequest<bool>;
/// <summary>
/// EN: Handler for DeleteBranchCommand.
/// VI: Handler cho DeleteBranchCommand.
/// </summary>
public class DeleteBranchCommandHandler : IRequestHandler<DeleteBranchCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<DeleteBranchCommandHandler> _logger;
public DeleteBranchCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<DeleteBranchCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> Handle(DeleteBranchCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken)
?? throw new DomainException("Shop not found");
if (shop.MerchantId != merchant.Id)
throw new DomainException("You do not have permission to modify this shop");
shop.RemoveBranch(request.BranchId);
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Branch {BranchId} removed from Shop {ShopId}", request.BranchId, request.ShopId);
return true;
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion

View File

@@ -0,0 +1,144 @@
// EN: Command to update an existing shop.
// VI: Command để cập nhật shop.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Shops;
/// <summary>
/// EN: Command to update an existing shop.
/// VI: Command để cập nhật shop.
/// </summary>
public record UpdateShopCommand : IRequest<bool>
{
/// <summary>
/// EN: Shop ID to update.
/// VI: ID shop cần cập nhật.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Shop name.
/// VI: Tên shop.
/// </summary>
public string Name { get; init; } = null!;
/// <summary>
/// EN: Shop description.
/// VI: Mô tả shop.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Phone number.
/// VI: Số điện thoại.
/// </summary>
public string? Phone { get; init; }
/// <summary>
/// EN: Email address.
/// VI: Địa chỉ email.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// EN: Website URL.
/// VI: URL website.
/// </summary>
public string? Website { get; init; }
/// <summary>
/// EN: Logo URL.
/// VI: URL logo.
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// EN: Cover image URL.
/// VI: URL ảnh bìa.
/// </summary>
public string? CoverImageUrl { get; init; }
}
/// <summary>
/// EN: Handler for UpdateShopCommand.
/// VI: Handler cho UpdateShopCommand.
/// </summary>
public class UpdateShopCommandHandler : IRequestHandler<UpdateShopCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<UpdateShopCommandHandler> _logger;
public UpdateShopCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<UpdateShopCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> Handle(UpdateShopCommand request, CancellationToken cancellationToken)
{
// EN: Get current user ID from claims
// VI: Lấy user ID hiện tại từ claims
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
throw new DomainException("User not authenticated");
}
// EN: Get merchant by user ID
// VI: Lấy merchant theo user ID
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
// EN: Get and validate shop ownership
// VI: Lấy và kiểm tra quyền sở hữu shop
var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken)
?? throw new DomainException("Shop not found");
if (shop.MerchantId != merchant.Id)
{
throw new DomainException("You do not have permission to update this shop");
}
// EN: Update shop information
// VI: Cập nhật thông tin shop
shop.UpdateInfo(request.Name, request.Description);
// EN: Update contact info
// VI: Cập nhật thông tin liên hệ
shop.UpdateContactInfo(new ContactInfo
{
Phone = request.Phone ?? string.Empty,
Email = request.Email,
Website = request.Website
});
// EN: Update images
// VI: Cập nhật hình ảnh
shop.UpdateImages(request.LogoUrl, request.CoverImageUrl);
// EN: Save changes
// VI: Lưu thay đổi
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Shop updated: {ShopId} by merchant {MerchantId}",
shop.Id, merchant.Id);
return true;
}
}

View File

@@ -0,0 +1,315 @@
// EN: Staff management commands.
// VI: Commands quản lý nhân viên.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Commands.Staff;
#region Invite Staff Command
/// <summary>
/// EN: Command to invite a new staff member.
/// VI: Command để mời nhân viên mới.
/// </summary>
public record InviteStaffCommand : IRequest<InviteStaffResult>
{
public string Email { get; init; } = null!;
public string Role { get; init; } = "Cashier";
public Guid? ShopId { get; init; }
public Guid? BranchId { get; init; }
}
public record InviteStaffResult(Guid StaffId, string Email, string Status);
/// <summary>
/// EN: Handler for InviteStaffCommand.
/// VI: Handler cho InviteStaffCommand.
/// </summary>
public class InviteStaffCommandHandler : IRequestHandler<InviteStaffCommand, InviteStaffResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<InviteStaffCommandHandler> _logger;
public InviteStaffCommandHandler(
IMerchantRepository merchantRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<InviteStaffCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<InviteStaffResult> Handle(InviteStaffCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
// EN: Parse role
// VI: Parse vai trò
var role = request.Role.ToLowerInvariant() switch
{
"cashier" => StaffRole.Cashier,
"waiter" => StaffRole.Waiter,
"manager" => StaffRole.Manager,
"admin" => StaffRole.Admin,
_ => StaffRole.Cashier
};
// EN: Create invitation
// VI: Tạo lời mời
var staff = MerchantStaff.Invite(
merchant.Id,
request.Email,
role,
StaffPermissions.ViewSales | StaffPermissions.ProcessPayment);
_staffRepository.Add(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Staff invited: {StaffId} to merchant {MerchantId}", staff.Id, merchant.Id);
return new InviteStaffResult(staff.Id, staff.Email ?? request.Email, staff.Status.Name);
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion
#region Update Staff Command
/// <summary>
/// EN: Command to update a staff member.
/// VI: Command để cập nhật nhân viên.
/// </summary>
public record UpdateStaffCommand : IRequest<bool>
{
public Guid StaffId { get; init; }
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string? Role { get; init; }
}
/// <summary>
/// EN: Handler for UpdateStaffCommand.
/// VI: Handler cho UpdateStaffCommand.
/// </summary>
public class UpdateStaffCommandHandler : IRequestHandler<UpdateStaffCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<UpdateStaffCommandHandler> _logger;
public UpdateStaffCommandHandler(
IMerchantRepository merchantRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<UpdateStaffCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> Handle(UpdateStaffCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
var staff = await _staffRepository.GetByIdAsync(request.StaffId, cancellationToken)
?? throw new DomainException("Staff not found");
if (staff.MerchantId != merchant.Id)
throw new DomainException("You do not have permission to update this staff");
staff.Update(request.EmployeeCode, request.Phone);
if (!string.IsNullOrEmpty(request.Role))
{
var role = request.Role.ToLowerInvariant() switch
{
"cashier" => StaffRole.Cashier,
"waiter" => StaffRole.Waiter,
"manager" => StaffRole.Manager,
"admin" => StaffRole.Admin,
_ => null
};
if (role != null)
staff.UpdateRole(role);
}
_staffRepository.Update(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Staff updated: {StaffId}", request.StaffId);
return true;
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion
#region Delete Staff Command
/// <summary>
/// EN: Command to delete/terminate a staff member.
/// VI: Command để xóa/chấm dứt nhân viên.
/// </summary>
public record DeleteStaffCommand(Guid StaffId) : IRequest<bool>;
/// <summary>
/// EN: Handler for DeleteStaffCommand.
/// VI: Handler cho DeleteStaffCommand.
/// </summary>
public class DeleteStaffCommandHandler : IRequestHandler<DeleteStaffCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<DeleteStaffCommandHandler> _logger;
public DeleteStaffCommandHandler(
IMerchantRepository merchantRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<DeleteStaffCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> Handle(DeleteStaffCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
var staff = await _staffRepository.GetByIdAsync(request.StaffId, cancellationToken)
?? throw new DomainException("Staff not found");
if (staff.MerchantId != merchant.Id)
throw new DomainException("You do not have permission to delete this staff");
staff.Terminate();
_staffRepository.Update(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Staff terminated: {StaffId}", request.StaffId);
return true;
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion
#region Accept Invite Command
/// <summary>
/// EN: Command to accept a staff invitation.
/// VI: Command để chấp nhận lời mời làm nhân viên.
/// </summary>
public record AcceptInviteCommand : IRequest<AcceptInviteResult>
{
public string InvitationToken { get; init; } = null!;
public string PinCode { get; init; } = null!;
}
public record AcceptInviteResult(Guid StaffId, string Status);
/// <summary>
/// EN: Handler for AcceptInviteCommand.
/// VI: Handler cho AcceptInviteCommand.
/// </summary>
public class AcceptInviteCommandHandler : IRequestHandler<AcceptInviteCommand, AcceptInviteResult>
{
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<AcceptInviteCommandHandler> _logger;
public AcceptInviteCommandHandler(
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<AcceptInviteCommandHandler> logger)
{
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<AcceptInviteResult> Handle(AcceptInviteCommand request, CancellationToken cancellationToken)
{
var userId = GetUserId();
// EN: Find staff by invitation token (simplified - in real app use secure token)
// VI: Tìm nhân viên theo token mời (đơn giản - app thực tế dùng token bảo mật)
if (!Guid.TryParse(request.InvitationToken, out var staffId))
throw new DomainException("Invalid invitation token");
var staff = await _staffRepository.GetByIdAsync(staffId, cancellationToken)
?? throw new DomainException("Invitation not found");
if (staff.Status != StaffStatus.Invited)
throw new DomainException("Invitation is no longer valid");
// EN: Accept invitation and set PIN
// VI: Chấp nhận lời mời và đặt PIN
staff.AcceptInvitation(userId);
staff.SetPinCode(request.PinCode);
_staffRepository.Update(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Staff invitation accepted: {StaffId} by user {UserId}", staffId, userId);
return new AcceptInviteResult(staff.Id, staff.Status.Name);
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion

View File

@@ -0,0 +1,118 @@
// EN: POS queries for staff information.
// VI: Queries POS cho thông tin nhân viên.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Queries.Pos;
#region Get POS Staff Query
/// <summary>
/// EN: Query to get current staff info for POS.
/// VI: Query để lấy thông tin nhân viên hiện tại cho POS.
/// </summary>
public record GetPosStaffQuery : IRequest<PosStaffDto?>;
/// <summary>
/// EN: Handler for GetPosStaffQuery.
/// VI: Handler cho GetPosStaffQuery.
/// </summary>
public class GetPosStaffQueryHandler : IRequestHandler<GetPosStaffQuery, PosStaffDto?>
{
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public GetPosStaffQueryHandler(
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor)
{
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
}
public async Task<PosStaffDto?> Handle(GetPosStaffQuery request, CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId == null) return null;
var staff = await _staffRepository.GetByUserIdAsync(userId.Value, cancellationToken);
if (staff == null) return null;
return new PosStaffDto
{
StaffId = staff.Id,
MerchantId = staff.MerchantId,
Email = staff.Email ?? string.Empty,
EmployeeCode = staff.EmployeeCode,
Phone = staff.Phone,
Role = staff.Role.Name,
Status = staff.Status.Name,
Permissions = (int)staff.Permissions,
HasPinCode = !string.IsNullOrEmpty(staff.PinCodeHash),
ShopAssignments = staff.ShopAssignments.Select(sm => new PosShopAssignmentDto
{
ShopId = sm.ShopId,
ShopRole = sm.Role.Name,
BranchId = sm.BranchId
}).ToList(),
RegisteredDevices = staff.DeviceTokens.Select(d => new PosDeviceDto
{
DeviceId = d.DeviceId,
DeviceName = d.DeviceName ?? string.Empty,
Platform = d.Platform,
LastUsedAt = d.LastUsedAt
}).ToList()
};
}
private Guid? GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
return null;
return userId;
}
}
#endregion
#region POS DTOs
/// <summary>
/// EN: POS staff DTO.
/// VI: DTO nhân viên POS.
/// </summary>
public record PosStaffDto
{
public Guid StaffId { get; init; }
public Guid MerchantId { get; init; }
public string Email { get; init; } = null!;
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string Role { get; init; } = null!;
public string Status { get; init; } = null!;
public int Permissions { get; init; }
public bool HasPinCode { get; init; }
public List<PosShopAssignmentDto> ShopAssignments { get; init; } = [];
public List<PosDeviceDto> RegisteredDevices { get; init; } = [];
}
public record PosShopAssignmentDto
{
public Guid ShopId { get; init; }
public string ShopRole { get; init; } = null!;
public Guid? BranchId { get; init; }
}
public record PosDeviceDto
{
public string DeviceId { get; init; } = null!;
public string DeviceName { get; init; } = null!;
public string Platform { get; init; } = null!;
public DateTime? LastUsedAt { get; init; }
}
#endregion

View File

@@ -0,0 +1,175 @@
// EN: Queries for branch management.
// VI: Queries cho quản lý chi nhánh.
using MediatR;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
namespace MerchantService.API.Application.Queries.Shops;
#region Get Branches Query
/// <summary>
/// EN: Query to get all branches of a shop.
/// VI: Query để lấy tất cả chi nhánh của shop.
/// </summary>
public record GetBranchesQuery(Guid ShopId) : IRequest<IReadOnlyList<ShopBranchDto>>;
/// <summary>
/// EN: Handler for GetBranchesQuery.
/// VI: Handler cho GetBranchesQuery.
/// </summary>
public class GetBranchesQueryHandler : IRequestHandler<GetBranchesQuery, IReadOnlyList<ShopBranchDto>>
{
private readonly IShopRepository _shopRepository;
public GetBranchesQueryHandler(IShopRepository shopRepository)
{
_shopRepository = shopRepository;
}
public async Task<IReadOnlyList<ShopBranchDto>> Handle(GetBranchesQuery request, CancellationToken cancellationToken)
{
var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken);
if (shop == null)
return [];
return shop.Branches.Select(b => new ShopBranchDto
{
Id = b.Id,
Name = b.Name,
Code = b.Code,
Address = new AddressDto
{
Street = b.Address.Street,
Ward = b.Address.Ward,
District = b.Address.District,
City = b.Address.City,
Province = b.Address.Province,
FullAddress = b.Address.FullAddress
},
Location = b.Location != null ? new GeoLocationDto
{
Latitude = b.Location.Latitude,
Longitude = b.Location.Longitude
} : null,
Phone = b.Phone,
IsActive = b.IsActive
}).ToList();
}
}
#endregion
#region Get Nearby Shops Query
/// <summary>
/// EN: Query to find shops with branches near a location.
/// VI: Query để tìm shops có chi nhánh gần vị trí.
/// </summary>
public record GetNearbyShopsQuery(
double Latitude,
double Longitude,
double RadiusKm = 5.0,
int Limit = 20) : IRequest<IReadOnlyList<NearbyShopDto>>;
/// <summary>
/// EN: DTO for nearby shop result.
/// VI: DTO cho kết quả tìm shop gần đây.
/// </summary>
public record NearbyShopDto
{
public Guid ShopId { get; init; }
public string Name { get; init; } = null!;
public string Slug { get; init; } = null!;
public string Category { get; init; } = null!;
public string? LogoUrl { get; init; }
public Guid BranchId { get; init; }
public string BranchName { get; init; } = null!;
public AddressDto Address { get; init; } = null!;
public double DistanceKm { get; init; }
}
/// <summary>
/// EN: Handler for GetNearbyShopsQuery.
/// VI: Handler cho GetNearbyShopsQuery.
/// </summary>
public class GetNearbyShopsQueryHandler : IRequestHandler<GetNearbyShopsQuery, IReadOnlyList<NearbyShopDto>>
{
private readonly IShopRepository _shopRepository;
public GetNearbyShopsQueryHandler(IShopRepository shopRepository)
{
_shopRepository = shopRepository;
}
public async Task<IReadOnlyList<NearbyShopDto>> Handle(GetNearbyShopsQuery request, CancellationToken cancellationToken)
{
// EN: Get all active shops with their branches
// VI: Lấy tất cả shops active với các chi nhánh
var shops = await _shopRepository.GetActiveShopsWithBranchesAsync(cancellationToken);
var results = new List<NearbyShopDto>();
foreach (var shop in shops)
{
foreach (var branch in shop.Branches.Where(b => b.IsActive && b.Location != null))
{
var distance = CalculateDistance(
request.Latitude, request.Longitude,
branch.Location!.Latitude, branch.Location.Longitude);
if (distance <= request.RadiusKm)
{
results.Add(new NearbyShopDto
{
ShopId = shop.Id,
Name = shop.Name,
Slug = shop.Slug,
Category = shop.Category.Name,
LogoUrl = shop.LogoUrl,
BranchId = branch.Id,
BranchName = branch.Name,
Address = new AddressDto
{
Street = branch.Address.Street,
Ward = branch.Address.Ward,
District = branch.Address.District,
City = branch.Address.City,
Province = branch.Address.Province,
FullAddress = branch.Address.FullAddress
},
DistanceKm = Math.Round(distance, 2)
});
}
}
}
return results
.OrderBy(r => r.DistanceKm)
.Take(request.Limit)
.ToList();
}
/// <summary>
/// EN: Calculate distance between two coordinates using Haversine formula.
/// VI: Tính khoảng cách giữa hai tọa độ sử dụng công thức Haversine.
/// </summary>
private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
{
const double R = 6371; // Earth's radius in km
var dLat = ToRadians(lat2 - lat1);
var dLon = ToRadians(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return R * c;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
}
#endregion

View File

@@ -0,0 +1,112 @@
// EN: Staff queries.
// VI: Queries cho nhân viên.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
namespace MerchantService.API.Application.Queries.Staff;
#region Get My Staff Query
/// <summary>
/// EN: Query to get current merchant's staff list.
/// VI: Query để lấy danh sách nhân viên của merchant hiện tại.
/// </summary>
public record GetMyStaffQuery : IRequest<IReadOnlyList<StaffDto>>;
/// <summary>
/// EN: Handler for GetMyStaffQuery.
/// VI: Handler cho GetMyStaffQuery.
/// </summary>
public class GetMyStaffQueryHandler : IRequestHandler<GetMyStaffQuery, IReadOnlyList<StaffDto>>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public GetMyStaffQueryHandler(
IMerchantRepository merchantRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor)
{
_merchantRepository = merchantRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
}
public async Task<IReadOnlyList<StaffDto>> Handle(GetMyStaffQuery request, CancellationToken cancellationToken)
{
var userId = GetUserId();
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken)
?? throw new DomainException("Merchant not found");
var staffList = await _staffRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
return staffList.Select(s => new StaffDto
{
Id = s.Id,
Email = s.Email ?? string.Empty,
EmployeeCode = s.EmployeeCode,
Phone = s.Phone,
Role = s.Role.Name,
Status = s.Status.Name,
Permissions = (int)s.Permissions,
HasPinCode = !string.IsNullOrEmpty(s.PinCodeHash),
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
ShopAssignments = s.ShopAssignments.Select(sm => new ShopAssignmentDto
{
ShopId = sm.ShopId,
ShopRole = sm.Role.Name,
BranchId = sm.BranchId
}).ToList()
}).ToList();
}
private Guid GetUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
throw new DomainException("User not authenticated");
return userId;
}
}
#endregion
#region Staff DTOs
/// <summary>
/// EN: Staff DTO.
/// VI: DTO nhân viên.
/// </summary>
public record StaffDto
{
public Guid Id { get; init; }
public string Email { get; init; } = null!;
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string Role { get; init; } = null!;
public string Status { get; init; } = null!;
public int Permissions { get; init; }
public bool HasPinCode { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<ShopAssignmentDto> ShopAssignments { get; init; } = [];
}
/// <summary>
/// EN: Shop assignment DTO.
/// VI: DTO gán shop.
/// </summary>
public record ShopAssignmentDto
{
public Guid ShopId { get; init; }
public string ShopRole { get; init; } = null!;
public Guid? BranchId { get; init; }
}
#endregion

View File

@@ -14,7 +14,7 @@ namespace MerchantService.API.Controllers;
/// VI: Controller để quản lý merchant.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("api/v1/merchants")]
[Authorize]
public class MerchantsController : ControllerBase
{
@@ -31,7 +31,7 @@ public class MerchantsController : ControllerBase
/// EN: Get current merchant's profile.
/// VI: Lấy profile của merchant hiện tại.
/// </summary>
[HttpGet("profile")]
[HttpGet("me")]
[ProducesResponseType(typeof(MerchantProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProfile()
@@ -70,7 +70,7 @@ public class MerchantsController : ControllerBase
/// EN: Update merchant information.
/// VI: Cập nhật thông tin merchant.
/// </summary>
[HttpPut("profile")]
[HttpPut("me")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateMerchantCommand command)
@@ -83,7 +83,7 @@ public class MerchantsController : ControllerBase
/// EN: Submit for verification.
/// VI: Nộp hồ sơ xác minh.
/// </summary>
[HttpPost("verification/submit")]
[HttpPost("me/verify")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SubmitVerification()

View File

@@ -0,0 +1,122 @@
// EN: POS Controller for Point of Sale operations.
// VI: Controller POS cho các thao tác bán hàng.
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MerchantService.API.Application.Commands.Pos;
using MerchantService.API.Application.Queries.Pos;
namespace MerchantService.API.Controllers;
/// <summary>
/// EN: Controller for POS (Point of Sale) operations.
/// VI: Controller cho các thao tác POS (Point of Sale).
/// </summary>
[ApiController]
[Route("api/v1/pos")]
public class PosController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<PosController> _logger;
public PosController(IMediator mediator, ILogger<PosController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Authenticate staff using PIN for POS operations.
/// VI: Xác thực nhân viên sử dụng PIN cho hoạt động POS.
/// </summary>
[HttpPost("auth/pin")]
[AllowAnonymous]
[ProducesResponseType(typeof(PinAuthResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> PinAuth([FromBody] PinAuthRequest request)
{
var command = new PinAuthCommand
{
StaffId = request.StaffId,
PinCode = request.PinCode,
DeviceId = request.DeviceId
};
var result = await _mediator.Send(command);
if (!result.IsAuthenticated)
{
return Unauthorized(new { message = result.ErrorMessage });
}
return Ok(result);
}
/// <summary>
/// EN: Register a device for POS/push notifications.
/// VI: Đăng ký thiết bị cho POS/push notifications.
/// </summary>
[HttpPost("devices/register")]
[Authorize]
[ProducesResponseType(typeof(RegisterDeviceResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> RegisterDevice([FromBody] RegisterDeviceRequest request)
{
try
{
var command = new RegisterDeviceCommand
{
DeviceId = request.DeviceId,
DeviceName = request.DeviceName,
Platform = request.Platform,
PushToken = request.PushToken
};
var result = await _mediator.Send(command);
_logger.LogInformation("Device registered: {DeviceId}", request.DeviceId);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Get current staff information for POS.
/// VI: Lấy thông tin nhân viên hiện tại cho POS.
/// </summary>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(PosStaffDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMe()
{
var result = await _mediator.Send(new GetPosStaffQuery());
if (result == null)
{
return NotFound(new { message = "Staff not found" });
}
return Ok(result);
}
}
#region Request Models
public record PinAuthRequest
{
public Guid StaffId { get; init; }
public string PinCode { get; init; } = null!;
public string? DeviceId { get; init; }
}
public record RegisterDeviceRequest
{
public string DeviceId { get; init; } = null!;
public string DeviceName { get; init; } = null!;
public string Platform { get; init; } = null!;
public string? PushToken { get; init; }
}
#endregion

View File

@@ -14,7 +14,7 @@ namespace MerchantService.API.Controllers;
/// VI: Controller để quản lý shop.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Route("api/v1/shops")]
[Authorize]
public class ShopsController : ControllerBase
{
@@ -31,7 +31,7 @@ public class ShopsController : ControllerBase
/// EN: Get current merchant's shops.
/// VI: Lấy danh sách shops của merchant hiện tại.
/// </summary>
[HttpGet("my-shops")]
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<ShopDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMyShops()
{
@@ -97,6 +97,43 @@ public class ShopsController : ControllerBase
}
}
/// <summary>
/// EN: Update an existing shop.
/// VI: Cập nhật shop.
/// </summary>
[HttpPut("{shopId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid shopId, [FromBody] UpdateShopRequest request)
{
try
{
var command = new UpdateShopCommand
{
ShopId = shopId,
Name = request.Name,
Description = request.Description,
Phone = request.Phone,
Email = request.Email,
Website = request.Website,
LogoUrl = request.LogoUrl,
CoverImageUrl = request.CoverImageUrl
};
await _mediator.Send(command);
_logger.LogInformation("Shop updated: {ShopId}", shopId);
return Ok(new { message = "Shop updated successfully" });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
{
return NotFound(new { message = ex.Message });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("permission"))
{
return StatusCode(StatusCodes.Status403Forbidden, new { message = ex.Message });
}
}
/// <summary>
/// EN: Publish a shop (make visible to customers).
/// VI: Công khai shop (hiển thị với khách hàng).
@@ -161,6 +198,91 @@ public class ShopsController : ControllerBase
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { shopId }, result);
}
/// <summary>
/// EN: Get all branches of a shop.
/// VI: Lấy tất cả chi nhánh của shop.
/// </summary>
[HttpGet("{shopId:guid}/branches")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetBranches(Guid shopId)
{
var result = await _mediator.Send(new GetBranchesQuery(shopId));
return Ok(result);
}
/// <summary>
/// EN: Update a branch.
/// VI: Cập nhật chi nhánh.
/// </summary>
[HttpPut("{shopId:guid}/branches/{branchId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateBranch(Guid shopId, Guid branchId, [FromBody] UpdateBranchRequest request)
{
try
{
var command = new UpdateBranchCommand
{
ShopId = shopId,
BranchId = branchId,
Name = request.Name,
Code = request.Code,
Street = request.Street,
Ward = request.Ward,
District = request.District,
City = request.City,
Province = request.Province,
Latitude = request.Latitude,
Longitude = request.Longitude,
Phone = request.Phone
};
await _mediator.Send(command);
return Ok(new { message = "Branch updated successfully" });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
{
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// EN: Delete a branch.
/// VI: Xóa chi nhánh.
/// </summary>
[HttpDelete("{shopId:guid}/branches/{branchId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteBranch(Guid shopId, Guid branchId)
{
try
{
await _mediator.Send(new DeleteBranchCommand(shopId, branchId));
return Ok(new { message = "Branch deleted successfully" });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
{
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// EN: Find shops with branches near a location.
/// VI: Tìm shops có chi nhánh gần vị trí.
/// </summary>
[HttpGet("nearby")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetNearbyShops(
[FromQuery] double lat,
[FromQuery] double lng,
[FromQuery] double radius = 5.0,
[FromQuery] int limit = 20)
{
var result = await _mediator.Send(new GetNearbyShopsQuery(lat, lng, radius, limit));
return Ok(result);
}
}
/// <summary>
@@ -180,3 +302,37 @@ public record AddBranchRequest
public double? Longitude { get; init; }
public string? Phone { get; init; }
}
/// <summary>
/// EN: Request model for updating a shop.
/// VI: Model request để cập nhật shop.
/// </summary>
public record UpdateShopRequest
{
public string Name { get; init; } = null!;
public string? Description { get; init; }
public string? Phone { get; init; }
public string? Email { get; init; }
public string? Website { get; init; }
public string? LogoUrl { get; init; }
public string? CoverImageUrl { get; init; }
}
/// <summary>
/// EN: Request model for updating a branch.
/// VI: Model request để cập nhật chi nhánh.
/// </summary>
public record UpdateBranchRequest
{
public string Name { get; init; } = null!;
public string? Code { get; init; }
public string Street { get; init; } = null!;
public string? Ward { get; init; }
public string District { get; init; } = null!;
public string City { get; init; } = null!;
public string? Province { get; init; }
public double? Latitude { get; init; }
public double? Longitude { get; init; }
public string? Phone { get; init; }
}

View File

@@ -0,0 +1,194 @@
// EN: Staff Controller for staff management.
// VI: Controller Staff để quản lý nhân viên.
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MerchantService.API.Application.Commands.Staff;
using MerchantService.API.Application.Queries.Staff;
namespace MerchantService.API.Controllers;
/// <summary>
/// EN: Controller for merchant staff management.
/// VI: Controller để quản lý nhân viên của merchant.
/// </summary>
[ApiController]
[Route("api/v1/merchants/me/staff")]
[Authorize]
public class StaffController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<StaffController> _logger;
public StaffController(IMediator mediator, ILogger<StaffController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get current merchant's staff list.
/// VI: Lấy danh sách nhân viên của merchant hiện tại.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<StaffDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMyStaff()
{
var result = await _mediator.Send(new GetMyStaffQuery());
return Ok(result);
}
/// <summary>
/// EN: Invite a new staff member.
/// VI: Mời nhân viên mới.
/// </summary>
[HttpPost("invite")]
[ProducesResponseType(typeof(InviteStaffResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> InviteStaff([FromBody] InviteStaffRequest request)
{
try
{
var command = new InviteStaffCommand
{
Email = request.Email,
Role = request.Role,
ShopId = request.ShopId,
BranchId = request.BranchId
};
var result = await _mediator.Send(command);
_logger.LogInformation("Staff invited: {Email}", request.Email);
return CreatedAtAction(nameof(GetMyStaff), result);
}
catch (Domain.Exceptions.DomainException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// EN: Update a staff member.
/// VI: Cập nhật nhân viên.
/// </summary>
[HttpPut("{staffId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateStaff(Guid staffId, [FromBody] UpdateStaffRequest request)
{
try
{
var command = new UpdateStaffCommand
{
StaffId = staffId,
EmployeeCode = request.EmployeeCode,
Phone = request.Phone,
Role = request.Role
};
await _mediator.Send(command);
return Ok(new { message = "Staff updated successfully" });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
{
return NotFound(new { message = ex.Message });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("permission"))
{
return StatusCode(StatusCodes.Status403Forbidden, new { message = ex.Message });
}
}
/// <summary>
/// EN: Delete/terminate a staff member.
/// VI: Xóa/chấm dứt nhân viên.
/// </summary>
[HttpDelete("{staffId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteStaff(Guid staffId)
{
try
{
await _mediator.Send(new DeleteStaffCommand(staffId));
return Ok(new { message = "Staff terminated successfully" });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
{
return NotFound(new { message = ex.Message });
}
catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("permission"))
{
return StatusCode(StatusCodes.Status403Forbidden, new { message = ex.Message });
}
}
}
/// <summary>
/// EN: Controller for staff public endpoints.
/// VI: Controller cho các endpoint công khai của staff.
/// </summary>
[ApiController]
[Route("api/v1/staff")]
[Authorize]
public class StaffPublicController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<StaffPublicController> _logger;
public StaffPublicController(IMediator mediator, ILogger<StaffPublicController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Accept a staff invitation.
/// VI: Chấp nhận lời mời làm nhân viên.
/// </summary>
[HttpPost("accept-invite")]
[ProducesResponseType(typeof(AcceptInviteResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AcceptInvite([FromBody] AcceptInviteRequest request)
{
try
{
var command = new AcceptInviteCommand
{
InvitationToken = request.InvitationToken,
PinCode = request.PinCode
};
var result = await _mediator.Send(command);
_logger.LogInformation("Staff invitation accepted: {StaffId}", result.StaffId);
return Ok(result);
}
catch (Domain.Exceptions.DomainException ex)
{
return BadRequest(new { message = ex.Message });
}
}
}
#region Request Models
public record InviteStaffRequest
{
public string Email { get; init; } = null!;
public string Role { get; init; } = "Cashier";
public Guid? ShopId { get; init; }
public Guid? BranchId { get; init; }
}
public record UpdateStaffRequest
{
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string? Role { get; init; }
}
public record AcceptInviteRequest
{
public string InvitationToken { get; init; } = null!;
public string PinCode { get; init; } = null!;
}
#endregion

View File

@@ -58,4 +58,10 @@ public interface IShopRepository : IRepository<Shop>
/// VI: Cập nhật shop.
/// </summary>
void Update(Shop shop);
/// <summary>
/// EN: Get all active shops with branches loaded for nearby search.
/// VI: Lấy tất cả shops active kèm chi nhánh để tìm kiếm gần đây.
/// </summary>
Task<IReadOnlyList<Shop>> GetActiveShopsWithBranchesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -82,4 +82,13 @@ public class ShopRepository : IShopRepository
{
_context.Entry(shop).State = EntityState.Modified;
}
/// <inheritdoc />
public async Task<IReadOnlyList<Shop>> GetActiveShopsWithBranchesAsync(CancellationToken cancellationToken = default)
{
return await _context.Shops
.Include(s => s.Branches)
.Where(s => !s.IsDeleted && s.StatusId == ShopStatus.Active.Id)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,466 @@
# Mining Service Architecture
## Overview
The Mining Service provides a gamified point mining system inspired by Pi Network, enabling users to accumulate Mining Points through daily engagement and community building.
```mermaid
%%{init: {'theme':'dark'}}%%
graph TD
subgraph API["🌐 API Layer"]
Controllers[Controllers]
Commands[Commands]
Queries[Queries]
end
subgraph Domain["⚙️ Domain Layer"]
Miner[Miner Aggregate]
Circle[Circle Aggregate]
Referral[Referral Aggregate]
end
subgraph Infra["💾 Infrastructure Layer"]
EF[EF Core]
Redis[(Redis Cache)]
RabbitMQ[RabbitMQ]
end
subgraph Data["🗄️ Data Storage"]
DB[(PostgreSQL)]
end
API --> Domain
Domain --> Infra
EF --> DB
style API fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
style Domain fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style Infra fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
style Data fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
```
## Architecture Patterns
### Domain-Driven Design (DDD)
- **Aggregates**: Miner, Circle, Referral
- **Entities**: MiningSession, MiningHistory, CircleMember
- **Value Objects**: MiningRate, MiningPoints, MiningSession
- **Domain Events**: MiningSessionStarted, PointsMined, CircleCompleted, ReferralActivated
### CQRS Pattern
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph Write["📝 Commands"]
C1[StartMiningCommand]
C2[ClaimRewardCommand]
C3[CreateCircleCommand]
C4[InviteToCircleCommand]
C5[ApplyReferralCommand]
end
subgraph Read["📖 Queries"]
Q1[GetMinerStatusQuery]
Q2[GetMiningHistoryQuery]
Q3[GetCircleQuery]
Q4[GetReferralsQuery]
Q5[GetLeaderboardQuery]
end
style Write fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style Read fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
```
## Domain Model
### Miner Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Miner {
+Guid Id
+Guid UserId
+MinerRole Role
+decimal TotalMinedPoints
+MiningRate CurrentRate
+MiningSession ActiveSession
+string ReferralCode
+Guid ReferredBy
+MinerStatus Status
+StartMiningSession()
+ClaimMiningReward()
+UpgradeRole()
+RecalculateMiningRate()
}
class MiningSession {
+Guid SessionId
+DateTime StartTime
+DateTime EndTime
+decimal AccumulatedPoints
+SessionStatus Status
}
class MiningRate {
+decimal BaseRate
+decimal CircleBonus
+decimal ReferralBonus
+decimal RoleBonus
+decimal TotalRate
}
class MiningHistory {
+Guid Id
+decimal PointsEarned
+string Source
+DateTime EarnedAt
}
Miner "1" --> "0..1" MiningSession : has
Miner --> MiningRate : uses
Miner "1" --> "*" MiningHistory : tracks
```
### Circle Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Circle {
+Guid Id
+Guid OwnerId
+string Name
+List~CircleMember~ Members
+decimal TrustScore
+decimal BonusMultiplier
+CircleStatus Status
+AddMember()
+RemoveMember()
+CalculateTrustScore()
+Validate()
}
class CircleMember {
+Guid Id
+Guid MinerId
+DateTime JoinedAt
+bool IsActive
}
Circle "1" --> "3..5" CircleMember : contains
```
### Referral Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Referral {
+Guid Id
+Guid ReferrerId
+Guid ReferredId
+string ReferralCode
+decimal BonusRate
+bool IsActive
+int Level
+DateTime CreatedAt
+Activate()
+Deactivate()
+CalculateBonus()
}
```
## Mining Rate Formula
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
Base["🎯 Base Rate<br/>0.25 MP/hour"] --> Multiply1((×))
Role["👤 Role Bonus<br/>+0-50%"] --> Multiply1
Multiply1 --> Multiply2((×))
Circle["🔵 Circle Bonus<br/>+25%"] --> Multiply2
Multiply2 --> Multiply3((×))
Referral["👥 Referral Bonus<br/>+25%/each"] --> Multiply3
Multiply3 --> Total["✅ Total Rate<br/>MP/hour"]
style Base fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:2px
style Role fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style Circle fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
style Referral fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style Total fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:3px
```
**Example Calculation:**
| Component | Value | Multiplier |
|-----------|-------|------------|
| Base Rate | 0.25 MP/hour | - |
| Role (Ambassador) | +25% | × 1.25 |
| Valid Circle | +25% | × 1.25 |
| 2 Referrals | +50% | × 1.50 |
| **Total** | **0.585 MP/hour** | **14.04 MP/day** |
## Database Schema
### ER Diagram
```mermaid
%%{init: {'theme':'dark'}}%%
erDiagram
Miner ||--o{ MiningSession : has
Miner ||--o{ MiningHistory : tracks
Miner ||--o| Circle : owns
Circle ||--|{ CircleMember : contains
Miner ||--o{ Referral : refers
Miner ||--o| Referral : referredBy
Miner {
uuid Id PK
uuid UserId UK
string Role
decimal TotalMinedPoints
string ReferralCode UK
uuid ReferredBy FK
string Status
datetime CreatedAt
}
MiningSession {
uuid Id PK
uuid MinerId FK
datetime StartTime
datetime EndTime
decimal AccumulatedPoints
string Status
}
MiningHistory {
uuid Id PK
uuid MinerId FK
decimal PointsEarned
string Source
datetime EarnedAt
}
Circle {
uuid Id PK
uuid OwnerId FK
string Name
decimal TrustScore
decimal BonusMultiplier
string Status
}
CircleMember {
uuid Id PK
uuid CircleId FK
uuid MinerId FK
datetime JoinedAt
bool IsActive
}
Referral {
uuid Id PK
uuid ReferrerId FK
uuid ReferredId FK
string ReferralCode
decimal BonusRate
bool IsActive
int Level
}
```
### Key Indexes
| Index | Columns | Purpose |
|-------|---------|---------|
| `IX_Miners_UserId` | UserId | Fast lookup by user |
| `IX_Miners_ReferralCode` | ReferralCode | Referral code lookup |
| `IX_MiningSessions_MinerId_Status` | MinerId, Status | Active session check |
| `IX_Referrals_ReferrerId` | ReferrerId | Referral list |
## API Flow
### Start Mining Session
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant Client as 📱 Client
participant API as 🌐 Mining API
participant Handler as ⚙️ CommandHandler
participant Miner as 👤 MinerAggregate
participant Calc as 🧮 RateCalculator
participant DB as 💾 PostgreSQL
participant Cache as ⚡ Redis
Client->>API: POST /api/v1/mining/start
API->>Handler: StartMiningCommand
Handler->>Miner: ValidateNoActiveSession()
Handler->>Calc: CalculateMiningRate()
Calc-->>Handler: MiningRate
Handler->>Miner: CreateSession(24h)
Miner->>DB: SaveSession()
Miner->>Cache: CacheSessionInfo(ttl: 24h)
API-->>Client: 200 OK { session_id, rate, end_time }
```
### Claim Mining Reward
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant Client as 📱 Client
participant API as 🌐 Mining API
participant Handler as ⚙️ CommandHandler
participant Miner as 👤 MinerAggregate
participant DB as 💾 PostgreSQL
participant MQ as 📨 RabbitMQ
Client->>API: POST /api/v1/mining/claim
API->>Handler: ClaimRewardCommand
Handler->>Miner: GetActiveSession()
alt Session Completed
Miner->>Miner: CalculateEarnedPoints()
Miner->>Miner: AddToTotalPoints()
Miner->>DB: SaveMiningHistory()
Miner->>MQ: Publish PointsMinedEvent
API-->>Client: 200 OK { earned_points, total_points }
else Session Not Ready
API-->>Client: 400 Bad Request
end
```
## Inter-Service Communication
### Service Dependencies
```mermaid
%%{init: {'theme':'dark'}}%%
graph TD
subgraph External["🔐 Authentication"]
IAM[IAM Service]
end
subgraph Core["⛏️ Mining Service"]
Mining[Mining Service]
end
subgraph Integration["🔗 Integrations"]
Social[Social Service]
Wallet[Wallet Service]
end
IAM -->|JWT Validation| Mining
Social <-->|Friend Data| Mining
Mining -->|Point Conversion| Wallet
style External fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
style Core fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:3px
style Integration fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
```
### Integration Events (RabbitMQ)
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph Publishers["📤 Publishers"]
IAM[IAM Service]
Social[Social Service]
Mining1[Mining Service]
end
subgraph Events["📨 Events"]
E1[UserRegisteredEvent]
E2[FriendAddedEvent]
E3[PointsMinedEvent]
end
subgraph Consumers["📥 Consumers"]
Mining2[Mining Service]
Wallet[Wallet Service]
end
IAM --> E1 --> Mining2
Social --> E2 --> Mining2
Mining1 --> E3 --> Wallet
style Events fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
```
## Deployment
### Docker Compose
```yaml
mining-service:
build:
context: ../..
dockerfile: services/mining-service-net/Dockerfile
environment:
- DATABASE_URL=${MINING_DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- RABBITMQ_URL=${RABBITMQ_URL}
- JWT_AUTHORITY=${IAM_SERVICE_URL}
labels:
- traefik.http.routers.mining.rule=PathPrefix(`/api/v1/mining`)
```
### Health Checks
| Endpoint | Check |
|----------|-------|
| `/health/live` | ✅ Service running |
| `/health/ready` | ✅ DB + Redis connected |
| `/health` | ✅ Full status |
## Security
### Rate Limiting
- 1 mining session start per 24 hours
- 10 circle invites per day
### Anti-Fraud
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart TD
Request([🚀 Request]) --> Device{🔍 Device Check}
Device -->|New Device| Flag[⚠️ Flag for Review]
Device -->|Known| IP{🌐 IP Check}
IP -->|Suspicious| Block[❌ Block]
IP -->|Normal| KYC{🔐 KYC Verified?}
KYC -->|No| Limited[⚠️ Limited Features]
KYC -->|Yes| Full[✅ Full Access]
style Request fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:3px
style Block fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
style Full fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style Device fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style IP fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style KYC fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
```
## Caching Strategy (Redis)
| Key Pattern | TTL | Purpose |
|-------------|-----|---------|
| `session:{minerId}` | 24h | Active session cache |
| `rate:{minerId}` | 1h | Mining rate cache |
| `leaderboard:daily` | 5m | Leaderboard cache |
## Monitoring
### Metrics
- Mining sessions started/day
- Points mined total
- Active miners count
- Referral conversion rate
### Logging
- Serilog structured logging
- Correlation IDs for tracing
- Prometheus + Grafana integration

View File

@@ -0,0 +1,515 @@
# Mining Service .NET
> **EN**: Mining Point management service with Pi Network-inspired mechanism for GoodGo Platform.
> **VI**: Dịch vụ quản lý Mining Point với cơ chế lấy cảm hứng từ Pi Network cho GoodGo Platform.
## Overview
The Mining Service provides a **gamified loyalty point mining system** inspired by Pi Network, allowing users to accumulate Mining Points (MP) through daily engagement, social referrals, and community building activities.
### Key Features
| Feature | Description |
|---------|-------------|
| **Daily Mining** | Tap-to-mine mechanism - users activate daily mining sessions |
| **Mining Rate** | Base rate increases through referrals and circle building |
| **🔥 Streak Bonus** | TikTok-style consecutive daily mining rewards |
| **Security Circles** | Trusted groups that boost mining rate and network security |
| **Referral System** | Multi-level referral bonuses for network growth |
| **User Roles** | Pioneer, Contributor, Ambassador, Node Operator tiers |
| **Point Conversion** | Convert Mining Points to platform loyalty points |
---
## Architecture Design
### System Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway (Traefik) │
└────────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────────▼────────────────────────────────────┐
│ Mining Service .NET │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Mining │ │ Circle │ │ Referral │ │ Rate │ │
│ │ Session │ │ Manager │ │ Tracker │ │ Calculator │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ MinerAggregate │ │ CircleAggregate │ │
│ │ - MiningSession │ │ - CircleMember │ │
│ │ - MiningHistory │ │ - TrustLevel │ │
│ │ - MiningRate │ │ - CircleBonus │ │
│ └─────────────────────┘ └─────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ RabbitMQ │ │
│ │ (EF Core) │ │ (Cache) │ │ (Events) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ IAM Service │ │ Wallet Service │ │ Social Service │
│ (Auth/Users) │ │ (Point Convert) │ │ (Friends) │
└───────────────┘ └─────────────────┘ └─────────────────┘
```
### Clean Architecture Structure
```
mining-service-net/
├── src/
│ ├── MiningService.API/ # API Layer
│ │ ├── Controllers/
│ │ │ ├── MiningController.cs # Mining session APIs
│ │ │ ├── CirclesController.cs # Security circle APIs
│ │ │ └── ReferralsController.cs # Referral APIs
│ │ └── Application/
│ │ ├── Commands/ # Write operations
│ │ └── Queries/ # Read operations
│ │
│ ├── MiningService.Domain/ # Domain Layer
│ │ ├── AggregatesModel/
│ │ │ ├── MinerAggregate/ # User mining profile
│ │ │ ├── CircleAggregate/ # Security circles
│ │ │ └── ReferralAggregate/ # Referral tracking
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── Services/ # Domain services
│ │
│ └── MiningService.Infrastructure/ # Infrastructure Layer
│ ├── EntityConfigurations/ # EF Core mappings
│ ├── Repositories/ # Data access
│ └── MiningServiceContext.cs # DbContext
├── tests/
│ ├── MiningService.UnitTests/
│ └── MiningService.FunctionalTests/
├── docs/
│ ├── en/
│ └── vi/
└── Dockerfile
```
---
## Domain Model
### Core Aggregates
#### 1. Miner Aggregate (User Mining Profile)
```
┌────────────────────────────────────────────────────────────────────┐
│ Miner (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Properties: │
│ - Id: Guid │
│ - UserId: Guid (from IAM Service) │
│ - Role: MinerRole (Pioneer/Contributor/Ambassador/NodeOperator) │
│ - TotalMinedPoints: decimal │
│ - CurrentMiningRate: MiningRate (Value Object) │
│ - CurrentSession: MiningSession? │
│ - SecurityCircle: Circle? │
│ - ReferralCode: string │
│ - ReferredBy: Guid? │
│ - Status: MinerStatus (Active/Suspended/Banned) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Behaviors: │
│ - StartMiningSession() → MiningSession │
│ - ClaimMiningReward() → MiningPoints │
│ - UpgradeRole(role) → void │
│ - JoinCircle(circle) → void │
│ - RecalculateMiningRate() → MiningRate │
└────────────────────────────────────────────────────────────────────┘
```
#### 2. Circle Aggregate (Security Circle)
```
┌────────────────────────────────────────────────────────────────────┐
│ Circle (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Properties: │
│ - Id: Guid │
│ - OwnerId: Guid (Miner who created the circle) │
│ - Members: List<CircleMember> (max 5) │
│ - Name: string │
│ - TrustScore: decimal (0-100) │
│ - BonusMultiplier: decimal │
│ - Status: CircleStatus (Active/Incomplete/Disbanded) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Behaviors: │
│ - AddMember(miner) → void │
│ - RemoveMember(minerId) → void │
│ - CalculateTrustScore() → decimal │
│ - CalculateBonusMultiplier() → decimal │
│ - Validate() → bool (min 3 members required) │
└────────────────────────────────────────────────────────────────────┘
```
#### 3. Referral Aggregate
```
┌────────────────────────────────────────────────────────────────────┐
│ Referral (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Properties: │
│ - Id: Guid │
│ - ReferrerId: Guid (who invited) │
│ - ReferredId: Guid (who was invited) │
│ - ReferralCode: string │
│ - BonusRate: decimal │
│ - IsActive: bool │
│ - Level: int (1 = direct, 2 = indirect) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Behaviors: │
│ - Activate() → void │
│ - Deactivate() → void │
│ - CalculateBonus(baseRate) → decimal │
└────────────────────────────────────────────────────────────────────┘
```
### Value Objects
```csharp
/// Mining Rate calculation
public record MiningRate(
decimal BaseRate, // Default: 0.25 MP/hour
decimal CircleBonus, // +0.25x for valid circle
decimal ReferralBonus, // +25% per active referral
decimal RoleBonus, // Based on role tier
decimal TotalRate // Combined rate
);
/// Mining Session tracking
public record MiningSession(
Guid SessionId,
DateTime StartTime,
DateTime EndTime, // StartTime + 24 hours
decimal AccumulatedPoints,
MiningSessionStatus Status // Active/Completed/Expired
);
/// Mining Points
public record MiningPoints(
decimal Amount,
DateTime EarnedAt,
string Source // Mining/Referral/CircleBonus/RoleBonus
);
```
---
## Mining Mechanism
### 🔥 Streak Bonus System (TikTok-style)
Consecutive daily mining rewards users with escalating bonuses:
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph Streak["🔥 Streak Tiers"]
D1["Day 1-2<br/>+0%"] --> D3["Day 3-6<br/>+10%"]
D3 --> D7["Day 7-13<br/>+25%"]
D7 --> D14["Day 14-29<br/>+50%"]
D14 --> D30["Day 30+<br/>+100%"]
end
style D1 fill:#7F8C8D,color:#ECF0F1,stroke:#5D6D7E,stroke-width:2px
style D3 fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
style D7 fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style D14 fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style D30 fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:3px
```
#### Streak Mechanics
| Streak Days | Bonus | Milestone Reward |
|-------------|-------|------------------|
| Day 1-2 | +0% | - |
| Day 3-6 | +10% | 🎁 3-day badge |
| Day 7-13 | +25% | 🎁 7-day badge + 50 MP bonus |
| Day 14-29 | +50% | 🎁 14-day badge + 100 MP bonus |
| Day 30-59 | +100% | 🔥 30-day badge + 300 MP bonus |
| Day 60-89 | +125% | 🏆 60-day badge + 500 MP bonus |
| Day 90+ | +150% | 👑 90-day badge + 1000 MP bonus |
#### Streak Protection Rules
- **Grace Period**: Miss 1 day → streak pauses (not reset)
- **Streak Freeze**: Use 1 Freeze Token to protect streak (earn 1 token per 7-day streak)
- **Streak Recovery**: Within 24h of missing → pay 50 MP to restore streak
- **Maximum Streak**: Unlimited (displayed on leaderboard)
#### Streak Value Object
```csharp
/// Streak tracking for consecutive mining
public record MiningStreak(
int CurrentStreak, // Current consecutive days
int LongestStreak, // Personal best
DateTime LastMiningDate, // Last successful claim
int FreezeTokens, // Available streak protections
bool IsGracePeriod, // Currently in grace period
decimal BonusMultiplier // Current streak bonus
);
```
---
### Mining Rate Formula
```
Total Mining Rate = Base Rate × (1 + Role) × (1 + Circle) × (1 + Referral) × (1 + Streak)
Where:
- Base Rate: 0.25 MP/hour (configurable)
- Role Multiplier: Pioneer=0%, Contributor=10%, Ambassador=25%, Node=50%
- Circle Bonus: 25% if valid circle (3-5 trusted members)
- Referral Bonus: 25% per active direct referral (capped at 100%)
- Streak Bonus: 0-150% based on consecutive daily mining
```
**Example with Streak:**
| Component | Value | Multiplier |
|-----------|-------|------------|
| Base Rate | 0.25 MP/hour | - |
| Role (Ambassador) | +25% | × 1.25 |
| Valid Circle | +25% | × 1.25 |
| 2 Referrals | +50% | × 1.50 |
| 30-Day Streak | +100% | × 2.00 |
| **Total** | **1.17 MP/hour** | **28.12 MP/day** |
### Mining Session Flow
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant U as 📱 User
participant A as 🌐 Mining API
participant M as ⚙️ MinerAggregate
participant R as 🧮 RateCalculator
participant DB as 💾 PostgreSQL
participant C as ⚡ Redis Cache
U->>A: POST /api/v1/mining/start
A->>M: StartMiningSession()
M->>R: CalculateMiningRate()
R-->>M: MiningRate
M->>M: CreateSession(24h duration)
M->>DB: SaveSession()
M->>C: CacheSessionInfo()
A-->>U: 200 OK { session_id, rate, end_time }
Note over U: 24 hours later...
U->>A: POST /api/v1/mining/claim
A->>M: ClaimMiningReward()
M->>M: CalculateEarnedPoints()
M->>M: AddToTotalPoints()
M->>DB: SaveMiningHistory()
A-->>U: 200 OK { earned_points, total_points }
```
### User Roles & Benefits
| Role | Requirements | Mining Bonus | Benefits |
|------|--------------|--------------|----------|
| **Pioneer** | Sign up | 0% | Basic mining |
| **Contributor** | Valid security circle (3+ members) | +10% | Circle bonus active |
| **Ambassador** | 5+ active referrals | +25% | Referral bonus cap increased |
| **Node Operator** | Run node software (future) | +50% | Network rewards |
---
## API Endpoints
### Mining APIs
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/mining/me` | Get current mining status |
| `POST` | `/api/v1/mining/start` | Start 24-hour mining session |
| `POST` | `/api/v1/mining/claim` | Claim mining rewards |
| `GET` | `/api/v1/mining/history` | Get mining history |
| `GET` | `/api/v1/mining/rate` | Get current mining rate breakdown |
| `GET` | `/api/v1/mining/leaderboard` | Get top miners leaderboard |
### Security Circle APIs
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/circles/me` | Get my security circle |
| `POST` | `/api/v1/circles` | Create security circle |
| `POST` | `/api/v1/circles/invite` | Invite member to circle |
| `POST` | `/api/v1/circles/accept/{inviteId}` | Accept circle invitation |
| `DELETE` | `/api/v1/circles/members/{memberId}` | Remove circle member |
| `GET` | `/api/v1/circles/trust-score` | Get circle trust score |
### Referral APIs
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/referrals/code` | Get my referral code |
| `GET` | `/api/v1/referrals` | List my referrals |
| `GET` | `/api/v1/referrals/stats` | Get referral statistics |
| `POST` | `/api/v1/referrals/apply` | Apply referral code (during signup) |
### Admin APIs
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/miners` | List all miners (paginated) |
| `PUT` | `/api/v1/admin/miners/{id}/suspend` | Suspend miner |
| `PUT` | `/api/v1/admin/miners/{id}/ban` | Ban miner |
| `PUT` | `/api/v1/admin/config/mining-rate` | Update base mining rate |
---
## Integration Points
### Service Dependencies
```
┌─────────────────────────────────────────────────────────────────────┐
│ Mining Service │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ Authenticates ┌──────────────────────┐ │
│ │ │◄────────────────────────│ IAM Service │ │
│ │ │ │ - User validation │ │
│ │ │ │ - JWT tokens │ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │ Mining │ Point Conversion ┌──────────────────────┐ │
│ │ Service │────────────────────────►│ Wallet Service │ │
│ │ │ │ - Convert MP to LP │ │
│ │ │ │ - Transaction │ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │ │ Social Graph ┌──────────────────────┐ │
│ │ │◄───────────────────────►│ Social Service │ │
│ │ │ │ - Friend list │ │
│ │ │ │ - Trust validation │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Integration Events (RabbitMQ)
| Event | Publisher | Consumer(s) | Description |
|-------|-----------|-------------|-------------|
| `MinerCreatedEvent` | Mining Service | IAM Service | New miner profile created |
| `MiningSessionStartedEvent` | Mining Service | - | Mining session started |
| `PointsMinedEvent` | Mining Service | Wallet Service | Points claimed, sync to wallet |
| `CircleCompletedEvent` | Mining Service | - | Security circle became valid |
| `ReferralActivatedEvent` | Mining Service | - | Referral became active |
| `UserRegisteredEvent` | IAM Service | Mining Service | Create miner profile |
| `FriendAddedEvent` | Social Service | Mining Service | Update circle suggestions |
---
## Tech Stack
| Component | Technology |
|-----------|------------|
| Framework | .NET 10 |
| Database | PostgreSQL (EF Core) |
| Caching | Redis (StackExchange.Redis) |
| Message Queue | RabbitMQ (MassTransit) |
| CQRS | MediatR |
| Validation | FluentValidation |
| API Docs | Swagger/OpenAPI |
| Logging | Serilog |
| Observability | Prometheus + Grafana |
---
## Configuration
### Environment Variables
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `DATABASE_URL` | PostgreSQL connection | Yes | - |
| `REDIS_URL` | Redis connection | Yes | - |
| `RABBITMQ_URL` | RabbitMQ connection | Yes | - |
| `JWT_AUTHORITY` | JWT issuer URL | Yes | - |
| `MINING_BASE_RATE` | Base mining rate (MP/hour) | No | 0.25 |
| `MINING_SESSION_HOURS` | Session duration | No | 24 |
| `CIRCLE_MIN_MEMBERS` | Minimum circle members | No | 3 |
| `CIRCLE_MAX_MEMBERS` | Maximum circle members | No | 5 |
| `REFERRAL_BONUS_PERCENT` | Bonus per referral | No | 25 |
| `REFERRAL_BONUS_CAP` | Max referral bonus | No | 100 |
---
## Security Considerations
### Anti-Fraud Measures
1. **Rate Limiting** - Max 1 mining session start per 24 hours
2. **Device Fingerprint** - Track device changes, flag suspicious activity
3. **IP Monitoring** - Detect multiple accounts from same IP
4. **Circle Validation** - Members must be real, active users
5. **Referral Verification** - Referrals must pass KYC to activate bonus
6. **Activity Scoring** - Penalize inactive or bot-like patterns
### Data Protection
- Mining history encrypted at rest
- Personal data follows GDPR compliance
- Audit logs for all admin actions
---
## Roadmap
### Phase 1: Core Mining (MVP)
- [x] Miner profile creation
- [x] Daily tap-to-mine sessions
- [x] Basic mining rate calculation
- [x] Mining history
### Phase 2: Social Features
- [ ] Security circles
- [ ] Referral system
- [ ] Circle bonus calculation
### Phase 3: Advanced Features
- [ ] User role progression
- [ ] Point conversion to wallet
- [ ] Leaderboards
### Phase 4: Node Network (Future)
- [ ] Node operator role
- [ ] Decentralized validation
- [ ] Network rewards
---
## Resources
- [Architecture Documentation](./ARCHITECTURE.md)
- [GoodGo Platform Docs](../../docs/)
- [Pi Network Whitepaper](https://minepi.com/white-paper) (Inspiration)
- [Wallet Service Integration](../wallet-service-net/)
## License
Proprietary - GoodGo Platform

View File

@@ -0,0 +1,467 @@
# Kiến Trúc Mining Service
## Tổng Quan
Mining Service cung cấp hệ thống đào điểm game hóa lấy cảm hứng từ Pi Network, cho phép người dùng tích lũy Mining Points thông qua hoạt động hàng ngày và xây dựng cộng đồng.
```mermaid
%%{init: {'theme':'dark'}}%%
graph TD
subgraph API["🌐 Tầng API"]
Controllers[Controllers]
Commands[Commands]
Queries[Queries]
end
subgraph Domain["⚙️ Tầng Domain"]
Miner[Miner Aggregate]
Circle[Circle Aggregate]
Referral[Referral Aggregate]
end
subgraph Infra["💾 Tầng Infrastructure"]
EF[EF Core]
Redis[(Redis Cache)]
RabbitMQ[RabbitMQ]
end
subgraph Data["🗄️ Lưu Trữ Dữ Liệu"]
DB[(PostgreSQL)]
end
API --> Domain
Domain --> Infra
EF --> DB
style API fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:3px
style Domain fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style Infra fill:#34495E,color:#ECF0F1,stroke:#2C3E50,stroke-width:2px
style Data fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
```
## Các Mẫu Kiến Trúc
### Domain-Driven Design (DDD)
- **Aggregates**: Miner, Circle, Referral
- **Entities**: MiningSession, MiningHistory, CircleMember
- **Value Objects**: MiningRate, MiningPoints, MiningSession
- **Domain Events**: MiningSessionStarted, PointsMined, CircleCompleted, ReferralActivated
### CQRS Pattern
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph Write["📝 Commands (Ghi)"]
C1[StartMiningCommand]
C2[ClaimRewardCommand]
C3[CreateCircleCommand]
C4[InviteToCircleCommand]
C5[ApplyReferralCommand]
end
subgraph Read["📖 Queries (Đọc)"]
Q1[GetMinerStatusQuery]
Q2[GetMiningHistoryQuery]
Q3[GetCircleQuery]
Q4[GetReferralsQuery]
Q5[GetLeaderboardQuery]
end
style Write fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style Read fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
```
## Domain Model
### Miner Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Miner {
+Guid Id
+Guid UserId
+MinerRole Role
+decimal TotalMinedPoints
+MiningRate CurrentRate
+MiningSession ActiveSession
+string ReferralCode
+Guid ReferredBy
+MinerStatus Status
+StartMiningSession()
+ClaimMiningReward()
+UpgradeRole()
+RecalculateMiningRate()
}
class MiningSession {
+Guid SessionId
+DateTime StartTime
+DateTime EndTime
+decimal AccumulatedPoints
+SessionStatus Status
}
class MiningRate {
+decimal BaseRate
+decimal CircleBonus
+decimal ReferralBonus
+decimal RoleBonus
+decimal TotalRate
}
class MiningHistory {
+Guid Id
+decimal PointsEarned
+string Source
+DateTime EarnedAt
}
Miner "1" --> "0..1" MiningSession : có
Miner --> MiningRate : sử dụng
Miner "1" --> "*" MiningHistory : theo dõi
```
### Circle Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Circle {
+Guid Id
+Guid OwnerId
+string Name
+List~CircleMember~ Members
+decimal TrustScore
+decimal BonusMultiplier
+CircleStatus Status
+AddMember()
+RemoveMember()
+CalculateTrustScore()
+Validate()
}
class CircleMember {
+Guid Id
+Guid MinerId
+DateTime JoinedAt
+bool IsActive
}
Circle "1" --> "3..5" CircleMember : chứa
```
### Referral Aggregate
```mermaid
%%{init: {'theme':'dark'}}%%
classDiagram
class Referral {
+Guid Id
+Guid ReferrerId
+Guid ReferredId
+string ReferralCode
+decimal BonusRate
+bool IsActive
+int Level
+DateTime CreatedAt
+Activate()
+Deactivate()
+CalculateBonus()
}
```
## Công Thức Tỷ Lệ Đào
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
Base["🎯 Tỷ Lệ Cơ Bản<br/>0.25 MP/giờ"] --> Multiply1((×))
Role["👤 Thưởng Vai Trò<br/>+0-50%"] --> Multiply1
Multiply1 --> Multiply2((×))
Circle["🔵 Thưởng Vòng Tròn<br/>+25%"] --> Multiply2
Multiply2 --> Multiply3((×))
Referral["👥 Thưởng Giới Thiệu<br/>+25%/người"] --> Multiply3
Multiply3 --> Total["✅ Tỷ Lệ Tổng<br/>MP/giờ"]
style Base fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:2px
style Role fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:2px
style Circle fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
style Referral fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style Total fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:3px
```
**Ví Dụ Tính Toán:**
| Thành Phần | Giá Trị | Hệ Số |
|------------|---------|-------|
| Tỷ Lệ Cơ Bản | 0.25 MP/giờ | - |
| Vai Trò (Ambassador) | +25% | × 1.25 |
| Vòng Tròn Hợp Lệ | +25% | × 1.25 |
| 2 Giới Thiệu | +50% | × 1.50 |
| **Tổng** | **0.585 MP/giờ** | **14.04 MP/ngày** |
## Database Schema
### ER Diagram
```mermaid
%%{init: {'theme':'dark'}}%%
erDiagram
Miner ||--o{ MiningSession : "có"
Miner ||--o{ MiningHistory : "theo dõi"
Miner ||--o| Circle : "sở hữu"
Circle ||--|{ CircleMember : "chứa"
Miner ||--o{ Referral : "giới thiệu"
Miner ||--o| Referral : "được giới thiệu bởi"
Miner {
uuid Id PK
uuid UserId UK
string Role
decimal TotalMinedPoints
string ReferralCode UK
uuid ReferredBy FK
string Status
datetime CreatedAt
}
MiningSession {
uuid Id PK
uuid MinerId FK
datetime StartTime
datetime EndTime
decimal AccumulatedPoints
string Status
}
MiningHistory {
uuid Id PK
uuid MinerId FK
decimal PointsEarned
string Source
datetime EarnedAt
}
Circle {
uuid Id PK
uuid OwnerId FK
string Name
decimal TrustScore
decimal BonusMultiplier
string Status
}
CircleMember {
uuid Id PK
uuid CircleId FK
uuid MinerId FK
datetime JoinedAt
bool IsActive
}
Referral {
uuid Id PK
uuid ReferrerId FK
uuid ReferredId FK
string ReferralCode
decimal BonusRate
bool IsActive
int Level
}
```
### Các Index Quan Trọng
| Index | Cột | Mục Đích |
|-------|-----|----------|
| `IX_Miners_UserId` | UserId | Tra cứu nhanh theo user |
| `IX_Miners_ReferralCode` | ReferralCode | Tra cứu mã giới thiệu |
| `IX_MiningSessions_MinerId_Status` | MinerId, Status | Kiểm tra phiên hoạt động |
| `IX_Referrals_ReferrerId` | ReferrerId | Danh sách giới thiệu |
## Luồng API
### Bắt Đầu Phiên Đào
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant Client as 📱 Client
participant API as 🌐 Mining API
participant Handler as ⚙️ CommandHandler
participant Miner as 👤 MinerAggregate
participant Calc as 🧮 RateCalculator
participant DB as 💾 PostgreSQL
participant Cache as ⚡ Redis
Client->>API: POST /api/v1/mining/start
API->>Handler: StartMiningCommand
Handler->>Miner: ValidateNoActiveSession()
Handler->>Calc: CalculateMiningRate()
Calc-->>Handler: MiningRate
Handler->>Miner: CreateSession(24 giờ)
Miner->>DB: SaveSession()
Miner->>Cache: CacheSessionInfo(ttl: 24h)
API-->>Client: 200 OK { session_id, rate, end_time }
```
### Nhận Thưởng Đào
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant Client as 📱 Client
participant API as 🌐 Mining API
participant Handler as ⚙️ CommandHandler
participant Miner as 👤 MinerAggregate
participant DB as 💾 PostgreSQL
participant MQ as 📨 RabbitMQ
Client->>API: POST /api/v1/mining/claim
API->>Handler: ClaimRewardCommand
Handler->>Miner: GetActiveSession()
alt Phiên Đã Hoàn Thành
Miner->>Miner: CalculateEarnedPoints()
Miner->>Miner: AddToTotalPoints()
Miner->>DB: SaveMiningHistory()
Miner->>MQ: Publish PointsMinedEvent
API-->>Client: 200 OK { earned_points, total_points }
else Phiên Chưa Sẵn Sàng
API-->>Client: 400 Bad Request
end
```
## Giao Tiếp Liên Dịch Vụ
### Phụ Thuộc Dịch Vụ
```mermaid
%%{init: {'theme':'dark'}}%%
graph TD
subgraph External["🔐 Xác Thực"]
IAM[IAM Service]
end
subgraph Core["⛏️ Mining Service"]
Mining[Mining Service]
end
subgraph Integration["🔗 Tích Hợp"]
Social[Social Service]
Wallet[Wallet Service]
end
IAM -->|Xác thực JWT| Mining
Social <-->|Dữ liệu bạn bè| Mining
Mining -->|Chuyển đổi điểm| Wallet
style External fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
style Core fill:#8E44AD,color:#ECF0F1,stroke:#7D3C98,stroke-width:3px
style Integration fill:#3498DB,color:#ECF0F1,stroke:#2980B9,stroke-width:2px
```
### Integration Events (RabbitMQ)
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph Publishers["📤 Nhà Xuất Bản"]
IAM[IAM Service]
Social[Social Service]
Mining1[Mining Service]
end
subgraph Events["📨 Sự Kiện"]
E1[UserRegisteredEvent]
E2[FriendAddedEvent]
E3[PointsMinedEvent]
end
subgraph Consumers["📥 Người Tiêu Thụ"]
Mining2[Mining Service]
Wallet[Wallet Service]
end
IAM --> E1 --> Mining2
Social --> E2 --> Mining2
Mining1 --> E3 --> Wallet
style Events fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
```
## Triển Khai
### Docker Compose
```yaml
mining-service:
build:
context: ../..
dockerfile: services/mining-service-net/Dockerfile
environment:
- DATABASE_URL=${MINING_DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- RABBITMQ_URL=${RABBITMQ_URL}
- JWT_AUTHORITY=${IAM_SERVICE_URL}
labels:
- traefik.http.routers.mining.rule=PathPrefix(`/api/v1/mining`)
```
### Health Checks
| Endpoint | Kiểm Tra |
|----------|----------|
| `/health/live` | ✅ Service đang chạy |
| `/health/ready` | ✅ DB + Redis đã kết nối |
| `/health` | ✅ Trạng thái đầy đủ |
## Bảo Mật
### Giới Hạn Tốc Độ
- 1 phiên đào mỗi 24 giờ
- 10 lời mời vòng tròn mỗi ngày
### Chống Gian Lận
```mermaid
%%{init: {'theme':'dark'}}%%
flowchart TD
Request([🚀 Yêu Cầu]) --> Device{🔍 Kiểm Tra Thiết Bị}
Device -->|Thiết Bị Mới| Flag[⚠️ Đánh Dấu Xem Xét]
Device -->|Đã Biết| IP{🌐 Kiểm Tra IP}
IP -->|Đáng Ngờ| Block[❌ Chặn]
IP -->|Bình Thường| KYC{🔐 Đã KYC?}
KYC -->|Chưa| Limited[⚠️ Tính Năng Hạn Chế]
KYC -->|Rồi| Full[✅ Truy Cập Đầy Đủ]
style Request fill:#2C3E50,color:#ECF0F1,stroke:#34495E,stroke-width:3px
style Block fill:#C0392B,color:#ECF0F1,stroke:#A93226,stroke-width:2px
style Full fill:#27AE60,color:#ECF0F1,stroke:#229954,stroke-width:2px
style Device fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style IP fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
style KYC fill:#E67E22,color:#ECF0F1,stroke:#D35400,stroke-width:2px
```
## Chiến Lược Cache (Redis)
| Mẫu Key | TTL | Mục Đích |
|---------|-----|----------|
| `session:{minerId}` | 24h | Cache phiên hoạt động |
| `rate:{minerId}` | 1h | Cache tỷ lệ đào |
| `leaderboard:daily` | 5m | Cache bảng xếp hạng |
## Giám Sát
### Metrics
- Số phiên đào bắt đầu/ngày
- Tổng điểm đã đào
- Số thợ đào hoạt động
- Tỷ lệ chuyển đổi giới thiệu
### Logging
- Serilog structured logging
- Correlation IDs để tracing
- Tích hợp Prometheus + Grafana

View File

@@ -0,0 +1,447 @@
# Mining Service .NET
> **EN**: Mining Point management service with Pi Network-inspired mechanism for GoodGo Platform.
> **VI**: Dịch vụ quản lý Mining Point với cơ chế lấy cảm hứng từ Pi Network cho GoodGo Platform.
## Tổng Quan
Mining Service cung cấp **hệ thống đào điểm thưởng game hóa** lấy cảm hứng từ Pi Network, cho phép người dùng tích lũy Mining Points (MP) thông qua hoạt động hàng ngày, giới thiệu bạn bè và xây dựng cộng đồng.
### Tính Năng Chính
| Tính Năng | Mô Tả |
|-----------|-------|
| **Đào Hàng Ngày** | Cơ chế tap-to-mine - người dùng kích hoạt phiên đào hàng ngày |
| **Tỷ Lệ Đào** | Tỷ lệ cơ bản tăng thông qua giới thiệu và xây dựng vòng tròn |
| **Vòng Tròn An Toàn** | Nhóm tin cậy giúp tăng tỷ lệ đào và bảo mật mạng |
| **Hệ Thống Giới Thiệu** | Thưởng giới thiệu đa cấp cho sự phát triển mạng lưới |
| **Vai Trò Người Dùng** | Các cấp Pioneer, Contributor, Ambassador, Node Operator |
| **Chuyển Đổi Điểm** | Chuyển đổi Mining Points thành điểm thưởng nền tảng |
---
## Thiết Kế Kiến Trúc
### Kiến Trúc Hệ Thống
```
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway (Traefik) │
└────────────────────────────────────┬────────────────────────────────┘
┌────────────────────────────────────▼────────────────────────────────┐
│ Mining Service .NET │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Mining │ │ Circle │ │ Referral │ │ Rate │ │
│ │ Session │ │ Manager │ │ Tracker │ │ Calculator │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ MinerAggregate │ │ CircleAggregate │ │
│ │ - MiningSession │ │ - CircleMember │ │
│ │ - MiningHistory │ │ - TrustLevel │ │
│ │ - MiningRate │ │ - CircleBonus │ │
│ └─────────────────────┘ └─────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ RabbitMQ │ │
│ │ (EF Core) │ │ (Cache) │ │ (Events) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ IAM Service │ │ Wallet Service │ │ Social Service │
│ (Auth/Users) │ │ (Chuyển Điểm) │ │ (Bạn Bè) │
└───────────────┘ └─────────────────┘ └─────────────────┘
```
### Cấu Trúc Clean Architecture
```
mining-service-net/
├── src/
│ ├── MiningService.API/ # Tầng API
│ │ ├── Controllers/
│ │ │ ├── MiningController.cs # APIs phiên đào
│ │ │ ├── CirclesController.cs # APIs vòng tròn an toàn
│ │ │ └── ReferralsController.cs # APIs giới thiệu
│ │ └── Application/
│ │ ├── Commands/ # Thao tác ghi
│ │ └── Queries/ # Thao tác đọc
│ │
│ ├── MiningService.Domain/ # Tầng Domain
│ │ ├── AggregatesModel/
│ │ │ ├── MinerAggregate/ # Hồ sơ đào của người dùng
│ │ │ ├── CircleAggregate/ # Vòng tròn an toàn
│ │ │ └── ReferralAggregate/ # Theo dõi giới thiệu
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── Services/ # Domain services
│ │
│ └── MiningService.Infrastructure/ # Tầng Infrastructure
│ ├── EntityConfigurations/ # EF Core mappings
│ ├── Repositories/ # Truy cập dữ liệu
│ └── MiningServiceContext.cs # DbContext
├── tests/
│ ├── MiningService.UnitTests/
│ └── MiningService.FunctionalTests/
├── docs/
│ ├── en/
│ └── vi/
└── Dockerfile
```
---
## Domain Model
### Core Aggregates
#### 1. Miner Aggregate (Hồ Sơ Đào Của Người Dùng)
```
┌────────────────────────────────────────────────────────────────────┐
│ Miner (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Thuộc Tính: │
│ - Id: Guid │
│ - UserId: Guid (từ IAM Service) │
│ - Role: MinerRole (Pioneer/Contributor/Ambassador/NodeOperator) │
│ - TotalMinedPoints: decimal │
│ - CurrentMiningRate: MiningRate (Value Object) │
│ - CurrentSession: MiningSession? │
│ - SecurityCircle: Circle? │
│ - ReferralCode: string │
│ - ReferredBy: Guid? │
│ - Status: MinerStatus (Active/Suspended/Banned) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Hành Vi: │
│ - StartMiningSession() → MiningSession │
│ - ClaimMiningReward() → MiningPoints │
│ - UpgradeRole(role) → void │
│ - JoinCircle(circle) → void │
│ - RecalculateMiningRate() → MiningRate │
└────────────────────────────────────────────────────────────────────┘
```
#### 2. Circle Aggregate (Vòng Tròn An Toàn)
```
┌────────────────────────────────────────────────────────────────────┐
│ Circle (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Thuộc Tính: │
│ - Id: Guid │
│ - OwnerId: Guid (Miner tạo vòng tròn) │
│ - Members: List<CircleMember> (tối đa 5) │
│ - Name: string │
│ - TrustScore: decimal (0-100) │
│ - BonusMultiplier: decimal │
│ - Status: CircleStatus (Active/Incomplete/Disbanded) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Hành Vi: │
│ - AddMember(miner) → void │
│ - RemoveMember(minerId) → void │
│ - CalculateTrustScore() → decimal │
│ - CalculateBonusMultiplier() → decimal │
│ - Validate() → bool (tối thiểu 3 thành viên) │
└────────────────────────────────────────────────────────────────────┘
```
#### 3. Referral Aggregate (Giới Thiệu)
```
┌────────────────────────────────────────────────────────────────────┐
│ Referral (Entity) │
├────────────────────────────────────────────────────────────────────┤
│ Thuộc Tính: │
│ - Id: Guid │
│ - ReferrerId: Guid (người mời) │
│ - ReferredId: Guid (người được mời) │
│ - ReferralCode: string │
│ - BonusRate: decimal │
│ - IsActive: bool │
│ - Level: int (1 = trực tiếp, 2 = gián tiếp) │
│ - CreatedAt: DateTime │
├────────────────────────────────────────────────────────────────────┤
│ Hành Vi: │
│ - Activate() → void │
│ - Deactivate() → void │
│ - CalculateBonus(baseRate) → decimal │
└────────────────────────────────────────────────────────────────────┘
```
### Value Objects
```csharp
/// Tính toán tỷ lệ đào
public record MiningRate(
decimal BaseRate, // Mặc định: 0.25 MP/giờ
decimal CircleBonus, // +0.25x cho vòng tròn hợp lệ
decimal ReferralBonus, // +25% mỗi giới thiệu hoạt động
decimal RoleBonus, // Dựa trên cấp vai trò
decimal TotalRate // Tỷ lệ tổng hợp
);
/// Theo dõi phiên đào
public record MiningSession(
Guid SessionId,
DateTime StartTime,
DateTime EndTime, // StartTime + 24 giờ
decimal AccumulatedPoints,
MiningSessionStatus Status // Active/Completed/Expired
);
/// Mining Points
public record MiningPoints(
decimal Amount,
DateTime EarnedAt,
string Source // Mining/Referral/CircleBonus/RoleBonus
);
```
---
## Cơ Chế Đào
### Công Thức Tỷ Lệ Đào
```
Tỷ Lệ Đào Tổng = Tỷ Lệ Cơ Bản × (1 + Hệ Số Vai Trò) × (1 + Thưởng Vòng Tròn) × (1 + Thưởng Giới Thiệu)
Trong đó:
- Tỷ Lệ Cơ Bản: 0.25 MP/giờ (có thể cấu hình)
- Hệ Số Vai Trò: Pioneer=0%, Contributor=10%, Ambassador=25%, Node=50%
- Thưởng Vòng Tròn: 25% nếu vòng tròn hợp lệ (3-5 thành viên tin cậy)
- Thưởng Giới Thiệu: 25% mỗi giới thiệu trực tiếp hoạt động (giới hạn 100%)
```
### Luồng Phiên Đào
```mermaid
%%{init: {'theme':'dark'}}%%
sequenceDiagram
participant U as 📱 Người Dùng
participant A as 🌐 Mining API
participant M as ⚙️ MinerAggregate
participant R as 🧮 RateCalculator
participant DB as 💾 PostgreSQL
participant C as ⚡ Redis Cache
U->>A: POST /api/v1/mining/start
A->>M: StartMiningSession()
M->>R: CalculateMiningRate()
R-->>M: MiningRate
M->>M: CreateSession(24 giờ)
M->>DB: SaveSession()
M->>C: CacheSessionInfo()
A-->>U: 200 OK { session_id, rate, end_time }
Note over U: 24 giờ sau...
U->>A: POST /api/v1/mining/claim
A->>M: ClaimMiningReward()
M->>M: CalculateEarnedPoints()
M->>M: AddToTotalPoints()
M->>DB: SaveMiningHistory()
A-->>U: 200 OK { earned_points, total_points }
```
### Vai Trò & Quyền Lợi Người Dùng
| Vai Trò | Yêu Cầu | Thưởng Đào | Quyền Lợi |
|---------|---------|------------|-----------|
| **Pioneer** | Đăng ký | 0% | Đào cơ bản |
| **Contributor** | Vòng tròn an toàn hợp lệ (3+ thành viên) | +10% | Thưởng vòng tròn hoạt động |
| **Ambassador** | 5+ giới thiệu hoạt động | +25% | Tăng giới hạn thưởng giới thiệu |
| **Node Operator** | Chạy phần mềm node (tương lai) | +50% | Thưởng mạng |
---
## API Endpoints
### APIs Đào
| Phương Thức | Endpoint | Mô Tả |
|-------------|----------|-------|
| `GET` | `/api/v1/mining/me` | Lấy trạng thái đào hiện tại |
| `POST` | `/api/v1/mining/start` | Bắt đầu phiên đào 24 giờ |
| `POST` | `/api/v1/mining/claim` | Nhận thưởng đào |
| `GET` | `/api/v1/mining/history` | Lấy lịch sử đào |
| `GET` | `/api/v1/mining/rate` | Lấy chi tiết tỷ lệ đào |
| `GET` | `/api/v1/mining/leaderboard` | Lấy bảng xếp hạng thợ đào hàng đầu |
### APIs Vòng Tròn An Toàn
| Phương Thức | Endpoint | Mô Tả |
|-------------|----------|-------|
| `GET` | `/api/v1/circles/me` | Lấy vòng tròn an toàn của tôi |
| `POST` | `/api/v1/circles` | Tạo vòng tròn an toàn |
| `POST` | `/api/v1/circles/invite` | Mời thành viên vào vòng tròn |
| `POST` | `/api/v1/circles/accept/{inviteId}` | Chấp nhận lời mời vòng tròn |
| `DELETE` | `/api/v1/circles/members/{memberId}` | Xóa thành viên vòng tròn |
| `GET` | `/api/v1/circles/trust-score` | Lấy điểm tin cậy vòng tròn |
### APIs Giới Thiệu
| Phương Thức | Endpoint | Mô Tả |
|-------------|----------|-------|
| `GET` | `/api/v1/referrals/code` | Lấy mã giới thiệu của tôi |
| `GET` | `/api/v1/referrals` | Danh sách giới thiệu của tôi |
| `GET` | `/api/v1/referrals/stats` | Lấy thống kê giới thiệu |
| `POST` | `/api/v1/referrals/apply` | Áp dụng mã giới thiệu (khi đăng ký) |
### APIs Quản Trị
| Phương Thức | Endpoint | Mô Tả |
|-------------|----------|-------|
| `GET` | `/api/v1/admin/miners` | Danh sách tất cả thợ đào (phân trang) |
| `PUT` | `/api/v1/admin/miners/{id}/suspend` | Tạm ngừng thợ đào |
| `PUT` | `/api/v1/admin/miners/{id}/ban` | Cấm thợ đào |
| `PUT` | `/api/v1/admin/config/mining-rate` | Cập nhật tỷ lệ đào cơ bản |
---
## Điểm Tích Hợp
### Phụ Thuộc Dịch Vụ
```
┌─────────────────────────────────────────────────────────────────────┐
│ Mining Service │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ Xác Thực ┌──────────────────────┐ │
│ │ │◄────────────────────────│ IAM Service │ │
│ │ │ │ - Xác thực user │ │
│ │ │ │ - JWT tokens │ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │ Mining │ Chuyển Đổi Điểm ┌──────────────────────┐ │
│ │ Service │────────────────────────►│ Wallet Service │ │
│ │ │ │ - Chuyển MP sang LP │ │
│ │ │ │ - Giao dịch │ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │ │ Đồ Thị Xã Hội ┌──────────────────────┐ │
│ │ │◄───────────────────────►│ Social Service │ │
│ │ │ │ - Danh sách bạn bè │ │
│ │ │ │ - Xác thực tin cậy │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Integration Events (RabbitMQ)
| Event | Publisher | Consumer(s) | Mô Tả |
|-------|-----------|-------------|-------|
| `MinerCreatedEvent` | Mining Service | IAM Service | Hồ sơ thợ đào mới được tạo |
| `MiningSessionStartedEvent` | Mining Service | - | Phiên đào đã bắt đầu |
| `PointsMinedEvent` | Mining Service | Wallet Service | Điểm đã nhận, đồng bộ với ví |
| `CircleCompletedEvent` | Mining Service | - | Vòng tròn an toàn trở nên hợp lệ |
| `ReferralActivatedEvent` | Mining Service | - | Giới thiệu trở nên hoạt động |
| `UserRegisteredEvent` | IAM Service | Mining Service | Tạo hồ sơ thợ đào |
| `FriendAddedEvent` | Social Service | Mining Service | Cập nhật gợi ý vòng tròn |
---
## Tech Stack
| Thành Phần | Công Nghệ |
|------------|-----------|
| Framework | .NET 10 |
| Database | PostgreSQL (EF Core) |
| Caching | Redis (StackExchange.Redis) |
| Message Queue | RabbitMQ (MassTransit) |
| CQRS | MediatR |
| Validation | FluentValidation |
| API Docs | Swagger/OpenAPI |
| Logging | Serilog |
| Observability | Prometheus + Grafana |
---
## Cấu Hình
### Biến Môi Trường
| Biến | Mô Tả | Bắt Buộc | Mặc Định |
|------|-------|----------|----------|
| `DATABASE_URL` | Kết nối PostgreSQL | Có | - |
| `REDIS_URL` | Kết nối Redis | Có | - |
| `RABBITMQ_URL` | Kết nối RabbitMQ | Có | - |
| `JWT_AUTHORITY` | URL JWT issuer | Có | - |
| `MINING_BASE_RATE` | Tỷ lệ đào cơ bản (MP/giờ) | Không | 0.25 |
| `MINING_SESSION_HOURS` | Thời gian phiên | Không | 24 |
| `CIRCLE_MIN_MEMBERS` | Số thành viên tối thiểu vòng tròn | Không | 3 |
| `CIRCLE_MAX_MEMBERS` | Số thành viên tối đa vòng tròn | Không | 5 |
| `REFERRAL_BONUS_PERCENT` | Thưởng mỗi giới thiệu | Không | 25 |
| `REFERRAL_BONUS_CAP` | Giới hạn thưởng giới thiệu | Không | 100 |
---
## Xem Xét Bảo Mật
### Biện Pháp Chống Gian Lận
1. **Giới Hạn Tốc Độ** - Tối đa 1 phiên đào mỗi 24 giờ
2. **Vân Tay Thiết Bị** - Theo dõi thay đổi thiết bị, đánh dấu hoạt động đáng ngờ
3. **Giám Sát IP** - Phát hiện nhiều tài khoản từ cùng IP
4. **Xác Thực Vòng Tròn** - Thành viên phải là người dùng thật, hoạt động
5. **Xác Minh Giới Thiệu** - Giới thiệu phải vượt qua KYC để kích hoạt thưởng
6. **Điểm Hoạt Động** - Phạt các mẫu không hoạt động hoặc giống bot
### Bảo Vệ Dữ Liệu
- Lịch sử đào được mã hóa khi lưu trữ
- Dữ liệu cá nhân tuân thủ GDPR
- Nhật ký kiểm toán cho tất cả hành động quản trị
---
## Roadmap
### Giai Đoạn 1: Core Mining (MVP)
- [x] Tạo hồ sơ thợ đào
- [x] Phiên đào tap-to-mine hàng ngày
- [x] Tính toán tỷ lệ đào cơ bản
- [x] Lịch sử đào
### Giai Đoạn 2: Tính Năng Xã Hội
- [ ] Vòng tròn an toàn
- [ ] Hệ thống giới thiệu
- [ ] Tính toán thưởng vòng tròn
### Giai Đoạn 3: Tính Năng Nâng Cao
- [ ] Tiến trình vai trò người dùng
- [ ] Chuyển đổi điểm sang ví
- [ ] Bảng xếp hạng
### Giai Đoạn 4: Mạng Node (Tương Lai)
- [ ] Vai trò node operator
- [ ] Xác thực phi tập trung
- [ ] Thưởng mạng
---
## Tài Nguyên
- [Tài Liệu Kiến Trúc](./ARCHITECTURE.md)
- [Tài Liệu GoodGo Platform](../../docs/)
- [Pi Network Whitepaper](https://minepi.com/white-paper) (Nguồn Cảm Hứng)
- [Tích Hợp Wallet Service](../wallet-service-net/)
## Giấy Phép
Proprietary - GoodGo Platform