24 KiB
24 KiB
Kiến Trúc Merchant Service
Tổng Quan Hệ Thống
Merchant Service quản lý Merchant (Shop Owner), Shop, và Merchant Staff cho hệ sinh thái GoodGo. Service này hỗ trợ đa loại hình kinh doanh: cửa hàng online, cửa hàng vật lý, và hybrid.
graph TB
subgraph Client["Client Layer"]
WEB["🌐 Merchant Portal"]
POS["💳 POS System"]
MOBILE["📱 Customer App"]
end
subgraph Gateway["API Gateway"]
TRAEFIK["⚡ Traefik"]
end
subgraph Services["Microservices"]
IAM["🔐 IAM Service<br/>(User & Roles)"]
MERCHANT["🏪 Merchant Service<br/>(Shop, Staff)"]
WALLET["💰 Wallet Service"]
MEMBER["👥 Membership Service"]
VOUCHER["🎁 Voucher Service"]
end
subgraph Data["Data Layer"]
PG[("🐘 PostgreSQL")]
end
WEB --> TRAEFIK
POS --> TRAEFIK
MOBILE --> TRAEFIK
TRAEFIK --> |"/api/v1/auth"| IAM
TRAEFIK --> |"/api/v1/merchants<br/>/api/v1/shops"| MERCHANT
TRAEFIK --> |"/api/v1/wallets"| WALLET
IAM --> |"JWT Token"| MERCHANT
MERCHANT --> |"Assign Roles"| IAM
MERCHANT --> |"Settlement"| WALLET
MERCHANT -.-> |"Customer Visit"| MEMBER
MERCHANT -.-> |"Voucher Redemption"| VOUCHER
MERCHANT --> PG
style IAM fill:#3b82f6,stroke:#1e40af,color:#fff
style MERCHANT fill:#f97316,stroke:#c2410c,color:#fff
style WALLET fill:#a855f7,stroke:#7e22ce,color:#fff
style MEMBER fill:#22c55e,stroke:#15803d,color:#fff
style VOUCHER fill:#ec4899,stroke:#be185d,color:#fff
style TRAEFIK fill:#6366f1,stroke:#4338ca,color:#fff
Important
Phân chia trách nhiệm:
- IAM Service: Quản lý User identity, Roles (Merchant, MerchantStaff)
- Merchant Service: Quản lý Merchant, Shop, ShopBranch, MerchantStaff
- Wallet Service: Settlement, Commission calculation
- Voucher Service: Gift Voucher (planned)
Các Lớp Clean Architecture
┌────────────────────────────────────────────────────────────┐
│ Lớp API (Presentation) │
│ Controllers, Commands, Queries, Validators, DTOs │
├────────────────────────────────────────────────────────────┤
│ Lớp Domain (Core) │
│ Entities, Aggregates, Value Objects, Domain Events │
│ Merchant, Shop, MerchantStaff Aggregates │
├────────────────────────────────────────────────────────────┤
│ Lớp Infrastructure │
│ DbContext, Repositories, Entity Configurations │
│ External Services (IAM Client, Wallet Client) │
└────────────────────────────────────────────────────────────┘
Domain Model
Tổng Quan Aggregates
classDiagram
class Merchant {
+Guid Id
+Guid UserId
+Guid? OrganizationId
+string BusinessName
+MerchantType Type
+MerchantStatus Status
+BusinessInfo BusinessInfo
+SettlementConfig SettlementConfig
+Register()
+Approve()
+Suspend()
+CreateShop()
}
class Shop {
+Guid Id
+Guid MerchantId
+string Name
+string Slug
+ShopType Type
+BusinessCategory Category
+ShopStatus Status
+ContactInfo ContactInfo
+OperatingHours? Hours
+AddBranch()
+AssignStaff()
+UpdateSettings()
}
class ShopBranch {
+Guid Id
+Guid ShopId
+string Name
+string Code
+Address Address
+GeoLocation? Location
+bool IsActive
}
class MerchantStaff {
+Guid Id
+Guid UserId
+Guid MerchantId
+StaffRole Role
+StaffStatus Status
+string? EmployeeCode
+StaffPermissions Permissions
+SetPinCode()
+RegisterDevice()
}
class ShopMember {
+Guid StaffId
+Guid ShopId
+Guid? BranchId
+ShopRole Role
+int? CustomPermissions
}
Merchant "1" --> "*" Shop : owns
Merchant "1" --> "*" MerchantStaff : employs
Shop "1" --> "*" ShopBranch : has
Shop "1" --> "*" ShopMember : assigns
MerchantStaff "1" --> "*" ShopMember : works at
style Merchant fill:#f97316,stroke:#c2410c,color:#fff
style Shop fill:#3b82f6,stroke:#1e40af,color:#fff
style MerchantStaff fill:#22c55e,stroke:#15803d,color:#fff
Merchant Aggregate (Aggregate Root)
public class Merchant : Entity, IAggregateRoot
{
private Guid _userId; // Reference to IAM User (Owner)
private Guid? _organizationId; // Reference to IAM Organization
private string _businessName;
private MerchantType _type; // Individual, Company
private MerchantStatus _status; // Pending, Active, Suspended, Banned
private MerchantVerification _verification;
private SettlementConfig _settlementConfig;
// Value Object for business details
public BusinessInfo BusinessInfo { get; private set; }
// Collections
private readonly List<Shop> _shops = new();
private readonly List<MerchantStaff> _staff = new();
public IReadOnlyCollection<Shop> Shops => _shops.AsReadOnly();
public IReadOnlyCollection<MerchantStaff> Staff => _staff.AsReadOnly();
/// <summary>
/// EN: Factory method to register a new merchant.
/// VI: Factory method để đăng ký merchant mới.
/// </summary>
public static Merchant Register(Guid userId, string businessName, MerchantType type)
{
var merchant = new Merchant
{
Id = Guid.NewGuid(),
_userId = userId,
_businessName = businessName,
_type = type,
_status = MerchantStatus.PendingApproval,
_verification = new MerchantVerification()
};
merchant.AddDomainEvent(new MerchantRegisteredDomainEvent(merchant));
return merchant;
}
/// <summary>
/// EN: Approve merchant application.
/// VI: Phê duyệt đơn đăng ký merchant.
/// </summary>
public void Approve(Guid approvedBy)
{
if (_status != MerchantStatus.PendingApproval)
throw new DomainException("Can only approve pending merchants");
_status = MerchantStatus.Active;
AddDomainEvent(new MerchantApprovedDomainEvent(this, approvedBy));
}
/// <summary>
/// EN: Create a new shop under this merchant.
/// VI: Tạo shop mới thuộc merchant này.
/// </summary>
public Shop CreateShop(string name, string slug, ShopType type, BusinessCategory category)
{
if (_status != MerchantStatus.Active)
throw new DomainException("Only active merchants can create shops");
var shop = new Shop(Id, name, slug, type, category);
_shops.Add(shop);
return shop;
}
}
Shop Aggregate (Aggregate Root)
public class Shop : Entity, IAggregateRoot
{
private Guid _merchantId;
private string _name;
private string _slug;
private ShopType _type;
private BusinessCategory _category;
private ShopStatus _status;
private ContactInfo _contactInfo;
private OperatingHours? _operatingHours;
private ShopSettings _settings;
// Physical locations
private readonly List<ShopBranch> _branches = new();
// Staff assignments
private readonly List<ShopMember> _members = new();
public IReadOnlyCollection<ShopBranch> Branches => _branches.AsReadOnly();
public IReadOnlyCollection<ShopMember> Members => _members.AsReadOnly();
/// <summary>
/// EN: Add a physical branch to the shop.
/// VI: Thêm chi nhánh vật lý cho shop.
/// </summary>
public ShopBranch AddBranch(string name, Address address, GeoLocation? location = null)
{
if (_type == ShopType.OnlineOnly)
throw new DomainException("Online-only shops cannot have physical branches");
var branch = new ShopBranch(Id, name, address, location);
_branches.Add(branch);
AddDomainEvent(new ShopBranchAddedDomainEvent(this, branch));
return branch;
}
/// <summary>
/// EN: Assign a staff member to this shop.
/// VI: Gán nhân viên cho shop này.
/// </summary>
public void AssignStaff(MerchantStaff staff, ShopRole role, Guid? branchId = null)
{
var member = new ShopMember(staff.Id, Id, role, branchId);
_members.Add(member);
AddDomainEvent(new StaffAssignedToShopDomainEvent(staff, this, role));
}
}
MerchantStaff Aggregate (Aggregate Root)
public class MerchantStaff : Entity, IAggregateRoot
{
private Guid _userId; // Reference to IAM User
private Guid _merchantId; // Employer
private StaffRole _role; // Cashier, Manager, Admin
private StaffStatus _status;
private string? _employeeCode;
private ContactInfo _contactInfo;
// For POS System quick authentication
private readonly List<DeviceToken> _deviceTokens = new();
private string? _pinCodeHash; // Hashed 4-6 digit PIN
// Permissions bitmask
private StaffPermissions _permissions;
/// <summary>
/// EN: Set PIN code for POS authentication.
/// VI: Đặt mã PIN cho xác thực POS.
/// </summary>
public void SetPinCode(string pin)
{
if (pin.Length < 4 || pin.Length > 6 || !pin.All(char.IsDigit))
throw new DomainException("PIN must be 4-6 digits");
_pinCodeHash = HashPin(pin);
AddDomainEvent(new StaffPinCodeSetDomainEvent(this));
}
/// <summary>
/// EN: Verify PIN code for POS login.
/// VI: Xác minh mã PIN cho đăng nhập POS.
/// </summary>
public bool VerifyPinCode(string pin)
{
if (string.IsNullOrEmpty(_pinCodeHash))
return false;
return VerifyHash(pin, _pinCodeHash);
}
/// <summary>
/// EN: Register a device for push notifications.
/// VI: Đăng ký thiết bị cho push notifications.
/// </summary>
public void RegisterDevice(string deviceId, string deviceName, string fcmToken)
{
var existingToken = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId);
if (existingToken != null)
{
existingToken.Update(fcmToken);
}
else
{
var token = new DeviceToken(deviceId, deviceName, fcmToken);
_deviceTokens.Add(token);
}
AddDomainEvent(new StaffDeviceRegisteredDomainEvent(this, deviceId));
}
}
Value Objects
/// <summary>
/// EN: Business information for merchant verification.
/// VI: Thông tin doanh nghiệp để xác minh merchant.
/// </summary>
public record BusinessInfo(
string? TaxId,
string? BusinessLicenseNumber,
string? CompanyRegistrationNumber,
DateTime? EstablishedDate
);
/// <summary>
/// EN: Settlement configuration for merchant payouts.
/// VI: Cấu hình thanh toán cho chi trả merchant.
/// </summary>
public record SettlementConfig(
BankAccount BankAccount,
decimal CommissionRate, // Platform fee (%)
SettlementCycle Cycle, // Daily, Weekly, Monthly
bool AutoSettlement
);
/// <summary>
/// EN: Bank account for settlement.
/// VI: Tài khoản ngân hàng để thanh toán.
/// </summary>
public record BankAccount(
string BankCode,
string BankName,
string AccountNumber,
string AccountHolderName
);
/// <summary>
/// EN: Physical address.
/// VI: Địa chỉ vật lý.
/// </summary>
public record Address(
string Street,
string Ward,
string District,
string City,
string Province,
string PostalCode,
string CountryCode = "VN"
);
/// <summary>
/// EN: Geographic location for map display and nearby search.
/// VI: Vị trí địa lý để hiển thị bản đồ và tìm kiếm gần đây.
/// </summary>
public record GeoLocation(double Latitude, double Longitude);
/// <summary>
/// EN: Contact information.
/// VI: Thông tin liên hệ.
/// </summary>
public record ContactInfo(
string Phone,
string? Email,
string? Website
);
/// <summary>
/// EN: Operating hours for physical shops.
/// VI: Giờ hoạt động cho cửa hàng vật lý.
/// </summary>
public record OperatingHours(
TimeOnly OpenTime,
TimeOnly CloseTime,
List<DayOfWeek> OpenDays
);
Enumerations
// Merchant Types
public class MerchantType : Enumeration
{
public static readonly MerchantType Individual = new(1, "Individual"); // Cá nhân
public static readonly MerchantType Company = new(2, "Company"); // Doanh nghiệp
}
// Merchant Status
public class MerchantStatus : Enumeration
{
public static readonly MerchantStatus PendingApproval = new(1, "PendingApproval");
public static readonly MerchantStatus Active = new(2, "Active");
public static readonly MerchantStatus Suspended = new(3, "Suspended");
public static readonly MerchantStatus Banned = new(4, "Banned");
}
// Shop Types
public class ShopType : Enumeration
{
public static readonly ShopType OnlineOnly = new(1, "OnlineOnly"); // Chỉ online
public static readonly ShopType PhysicalOnly = new(2, "PhysicalOnly"); // Chỉ cửa hàng
public static readonly ShopType Hybrid = new(3, "Hybrid"); // Cả hai
}
// Business Categories (Ngành nghề)
public class BusinessCategory : Enumeration
{
public static readonly BusinessCategory FoodBeverage = new(1, "FoodBeverage");
public static readonly BusinessCategory Fashion = new(2, "Fashion");
public static readonly BusinessCategory Electronics = new(3, "Electronics");
public static readonly BusinessCategory Healthcare = new(4, "Healthcare");
public static readonly BusinessCategory Beauty = new(5, "Beauty");
public static readonly BusinessCategory Education = new(6, "Education");
public static readonly BusinessCategory Entertainment = new(7, "Entertainment");
public static readonly BusinessCategory Services = new(8, "Services");
public static readonly BusinessCategory Other = new(99, "Other");
}
// Shop Roles (for Staff assignment at shop level)
public class ShopRole : Enumeration
{
public static readonly ShopRole Cashier = new(1, "Cashier"); // Thu ngân
public static readonly ShopRole Waiter = new(2, "Waiter"); // Phục vụ
public static readonly ShopRole Manager = new(3, "Manager"); // Quản lý
public static readonly ShopRole Owner = new(4, "Owner"); // Chủ shop
}
// Staff Permissions (Bitmask)
[Flags]
public enum StaffPermissions
{
None = 0,
ViewSales = 1,
ProcessPayment = 2,
RefundOrder = 4,
ManageInventory = 8,
ViewReports = 16,
ManageStaff = 32,
ManageSettings = 64,
All = int.MaxValue
}
Domain Events
| Event | Trigger | Mục Đích |
|---|---|---|
MerchantRegisteredDomainEvent |
Đăng ký merchant mới | Notify admin for review |
MerchantApprovedDomainEvent |
Admin phê duyệt | Assign "Merchant" role via IAM |
MerchantSuspendedDomainEvent |
Tạm ngưng merchant | Notify merchant, disable shops |
ShopCreatedDomainEvent |
Tạo shop mới | Analytics, indexing |
ShopBranchAddedDomainEvent |
Thêm chi nhánh | Geo-indexing for nearby search |
StaffInvitedDomainEvent |
Mời nhân viên | Send invitation email |
StaffJoinedDomainEvent |
Nhân viên chấp nhận | Assign "MerchantStaff" role via IAM |
StaffDeviceRegisteredDomainEvent |
Đăng ký thiết bị POS | Enable push notifications |
CQRS Pattern
Write Side (Commands)
flowchart LR
subgraph Commands["✏️ Commands"]
CMD1["RegisterMerchantCommand"]
CMD2["CreateShopCommand"]
CMD3["AddShopBranchCommand"]
CMD4["InviteStaffCommand"]
CMD5["SetStaffPinCommand"]
end
subgraph Handlers["⚙️ Command Handlers"]
H1["RegisterMerchantHandler"]
H2["CreateShopHandler"]
H3["AddShopBranchHandler"]
H4["InviteStaffHandler"]
H5["SetStaffPinHandler"]
end
subgraph Domain["🏛️ Domain Model"]
DM["EF Core<br/>Repository"]
end
DB[("🐘 Database")]
CMD1 --> H1 --> DM --> DB
CMD2 --> H2 --> DM
CMD3 --> H3 --> DM
CMD4 --> H4 --> DM
CMD5 --> H5 --> DM
style CMD1 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD2 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD3 fill:#ef4444,stroke:#b91c1c,color:#fff
Read Side (Queries)
flowchart LR
subgraph Queries["📖 Queries"]
Q1["GetMerchantByIdQuery"]
Q2["GetShopsByMerchantQuery"]
Q3["GetNearbyShopsQuery"]
Q4["GetStaffByShopQuery"]
end
subgraph Handlers["🔍 Query Handlers"]
QH1["GetMerchantByIdHandler"]
QH2["GetShopsByMerchantHandler"]
QH3["GetNearbyShopsHandler"]
QH4["GetStaffByShopHandler"]
end
subgraph ReadModel["📊 Read Model"]
RM["Dapper / Raw SQL"]
end
DB[("🐘 Database")]
Q1 --> QH1 --> RM --> DB
Q2 --> QH2 --> RM
Q3 --> QH3 --> RM
Q4 --> QH4 --> RM
style Q1 fill:#22c55e,stroke:#15803d,color:#fff
style Q2 fill:#22c55e,stroke:#15803d,color:#fff
style Q3 fill:#22c55e,stroke:#15803d,color:#fff
Database Schema
erDiagram
MERCHANTS ||--o{ SHOPS : owns
MERCHANTS ||--o{ MERCHANT_STAFF : employs
SHOPS ||--o{ SHOP_BRANCHES : has
SHOPS ||--o{ SHOP_MEMBERS : assigns
MERCHANT_STAFF ||--o{ SHOP_MEMBERS : works_at
MERCHANT_STAFF ||--o{ DEVICE_TOKENS : has
MERCHANTS {
uuid id PK
uuid user_id FK "IAM.users"
uuid organization_id FK "IAM.organizations"
varchar business_name
int type_id FK
int status_id FK
varchar tax_id
varchar business_license_number
varchar bank_account_number
decimal commission_rate
int settlement_cycle
timestamp created_at
}
SHOPS {
uuid id PK
uuid merchant_id FK
varchar name
varchar slug UK
int type_id FK
int category_id FK
int status_id FK
varchar phone
jsonb operating_hours
jsonb settings
varchar logo_url
timestamp created_at
}
SHOP_BRANCHES {
uuid id PK
uuid shop_id FK
varchar name
varchar code
varchar street
varchar district
varchar city
decimal latitude
decimal longitude
boolean is_active
}
MERCHANT_STAFF {
uuid id PK
uuid user_id FK "IAM.users"
uuid merchant_id FK
int role_id FK
int status_id FK
varchar employee_code
varchar pin_code_hash
int permissions
timestamp joined_at
}
SHOP_MEMBERS {
uuid id PK
uuid staff_id FK
uuid shop_id FK
uuid branch_id FK
int shop_role_id FK
int custom_permissions
boolean is_primary
}
DEVICE_TOKENS {
uuid id PK
uuid staff_id FK
varchar device_id UK
varchar device_name
varchar fcm_token
varchar platform
timestamp last_used_at
}
Inter-Service Communication
Synchronous (HTTP)
| Target Service | Use Case | Method |
|---|---|---|
| IAM Service | Validate user token | GET /connect/userinfo |
| IAM Service | Assign Merchant role | POST /api/v1/users/{id}/roles |
| Wallet Service | Check merchant balance | GET /api/v1/wallets/{merchantId} |
Asynchronous (Events)
flowchart LR
MERCHANT["🏪 Merchant Service"]
BUS["📬 Event Bus"]
IAM["🔐 IAM Service"]
WALLET["💰 Wallet Service"]
NOTIF["🔔 Notification"]
MERCHANT --> |MerchantApprovedEvent| BUS
MERCHANT --> |StaffJoinedEvent| BUS
MERCHANT --> |ShopCreatedEvent| BUS
BUS --> |Subscribe| IAM
BUS --> |Subscribe| WALLET
BUS --> |Subscribe| NOTIF
IAM --> |Assign Role| IAM
WALLET --> |Create Wallet| WALLET
NOTIF --> |Send Email| NOTIF
style MERCHANT fill:#f97316,stroke:#c2410c,color:#fff
style BUS fill:#ef4444,stroke:#b91c1c,color:#fff
style IAM fill:#3b82f6,stroke:#1e40af,color:#fff
Bảo Mật
Authorization
| Role | Permissions |
|---|---|
User |
Register merchant, Accept staff invite |
Merchant |
Manage own shops, Manage own staff |
MerchantStaff |
Access POS, View assigned shop data |
Admin |
Approve/suspend merchants, View all data |
POS Authentication
sequenceDiagram
participant POS as POS Device
participant API as Merchant Service
participant IAM as IAM Service
POS->>API: POST /api/v1/pos/auth/pin<br/>{userId, pin, deviceId}
API->>API: Verify PIN hash
alt PIN Valid
API->>IAM: Validate user & role
IAM-->>API: OK
API->>API: Generate short-lived token
API-->>POS: {posToken, expiresIn: 8h}
else PIN Invalid
API-->>POS: 401 Unauthorized
end
Hạ Tầng
Docker Compose
merchant-service-net:
build:
context: ../..
dockerfile: services/merchant-service-net/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=merchant;...
- Jwt__Authority=http://iam-service-net:8080
- IamService__BaseUrl=http://iam-service-net:8080
- WalletService__BaseUrl=http://wallet-service-net:8080
depends_on:
- postgres
- iam-service-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.merchant.rule=PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`) || PathPrefix(`/api/v1/pos`)"
- "traefik.http.services.merchant.loadbalancer.server.port=8080"
Health Checks
| Endpoint | Kiểm tra | Mục đích |
|---|---|---|
/health |
Tất cả dependencies | Trạng thái đầy đủ |
/health/live |
App đang chạy | Kubernetes liveness |
/health/ready |
DB + IAM Service | Kubernetes readiness |
Giám Sát
Metrics (Prometheus)
merchant_registrations_total- Số merchant đăng kýmerchant_approvals_total- Số merchant được phê duyệtshop_creations_total- Số shop được tạopos_auth_attempts_total- Số lần đăng nhập POSpos_auth_failures_total- Số lần đăng nhập POS thất bại
Logging
Structured Serilog logging với:
- Request/Response logging
- Domain event logging
- POS authentication audit trail
- Error tracking với stack traces
Xử Lý Lỗi
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
MERCHANT_NOT_FOUND |
404 | Merchant không tồn tại |
MERCHANT_NOT_ACTIVE |
400 | Merchant chưa được phê duyệt |
SHOP_SLUG_EXISTS |
409 | Slug shop đã tồn tại |
STAFF_ALREADY_EXISTS |
409 | Nhân viên đã tồn tại |
INVALID_PIN |
401 | PIN không đúng |
DEVICE_NOT_REGISTERED |
403 | Thiết bị chưa đăng ký |
Response Format
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Bad Request",
"status": 400,
"detail": "Only active merchants can create shops.",
"errorCode": "MERCHANT_NOT_ACTIVE",
"traceId": "00-1234567890abcdef-1234567890abcdef-00"
}