fix: resolve 12 critical/high issues from code review across backend, frontend, and infra
Backend (7 fixes):
- wallet-service: remove conflicting EF Ignore() calls for mapped backing fields
- fnb-engine: remove KitchenTicket short constructor that set productId=orderItemId
- fnb-engine: replace fire-and-forget Task.Run with direct await for inventory deduction
- TenantMiddleware: implement PostgreSQL RLS SET LOCAL in 4 services (wallet, fnb, inventory, catalog)
- order-service: fix SQL injection pattern in TenantMiddleware with Guid.ToString("D")
- order-service: add ValidateShopAccess() authorization check in SignalR PosHub
- 4 services: register IDbConnection (NpgsqlConnection) in DI for RLS middleware
Frontend (3 fixes):
- PosDataService: return Success=false (not true) when PayOrder response parsing fails
- QrPayment: add _disposed guard to prevent timer race condition after component disposal
- BFF OrderController: add [Authorize] attribute to require JWT for all endpoints
Infrastructure (3 fixes):
- docker-compose: upgrade PostgreSQL 15-alpine to 16-alpine per project spec
- init-databases.sh: add 4 missing marketing service databases (mkt_*)
- Traefik routes: add wallet, catalog, booking routers and /api/v1/stock path
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,7 @@ else
|
||||
private bool _isLoading = true;
|
||||
private bool _isProcessing;
|
||||
private string? _errorMessage;
|
||||
private bool _disposed = false;
|
||||
|
||||
// EN: QR providers / VI: Nhà cung cấp QR
|
||||
private readonly string[] _providers = { "VietQR", "MoMo", "ZaloPay" };
|
||||
@@ -160,7 +161,7 @@ else
|
||||
{
|
||||
_countdownTimer = new Timer(_ =>
|
||||
{
|
||||
if (_timerSeconds > 0)
|
||||
if (!_disposed && _timerSeconds > 0)
|
||||
{
|
||||
_timerSeconds--;
|
||||
InvokeAsync(StateHasChanged);
|
||||
@@ -227,5 +228,9 @@ else
|
||||
NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}");
|
||||
}
|
||||
|
||||
public void Dispose() => _countdownTimer?.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_countdownTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +806,7 @@ public class PosDataService
|
||||
}
|
||||
return System.Text.Json.JsonSerializer.Deserialize<PayOrderResponse>(json, _jsonOptions);
|
||||
}
|
||||
catch { return new PayOrderResponse(true, null, null, null, null, null); }
|
||||
catch { return new PayOrderResponse(false, null, null, null, null, "Không thể xử lý phản hồi từ máy chủ"); }
|
||||
}
|
||||
return new PayOrderResponse(false, null, null, null, null, "Payment failed");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WebClientTpos.Server.Infrastructure;
|
||||
using WebClientTpos.Server.Models;
|
||||
@@ -10,6 +11,7 @@ namespace WebClientTpos.Server.Controllers;
|
||||
/// VI: Controller đơn hàng — proxy đến OrderService cho đơn hàng, POS thanh toán, dashboard.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/bff")]
|
||||
public class OrderController : ControllerBase
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
|
||||
# PostgreSQL 16 - Shared Database Server
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres-local
|
||||
environment:
|
||||
- POSTGRES_USER=goodgo
|
||||
|
||||
@@ -9,26 +9,30 @@
|
||||
set -e
|
||||
|
||||
DATABASES=(
|
||||
"ads_analytics_service"
|
||||
"ads_billing_service"
|
||||
"ads_manager_service"
|
||||
"ads_serving_service"
|
||||
"ads_tracking_service"
|
||||
"booking_service"
|
||||
"catalog_service"
|
||||
"chat_service"
|
||||
"fnb_engine"
|
||||
"iam_service"
|
||||
"storage_service"
|
||||
"inventory_service"
|
||||
"membership_service"
|
||||
"merchant_service"
|
||||
"wallet_service"
|
||||
"chat_service"
|
||||
"social_service"
|
||||
"mining_service"
|
||||
"mission_service"
|
||||
"promotion_service"
|
||||
"catalog_service"
|
||||
"mkt_facebook_service"
|
||||
"mkt_whatsapp_service"
|
||||
"mkt_x_service"
|
||||
"mkt_zalo_service"
|
||||
"order_service"
|
||||
"inventory_service"
|
||||
"fnb_engine"
|
||||
"booking_service"
|
||||
"ads_manager_service"
|
||||
"ads_analytics_service"
|
||||
"ads_serving_service"
|
||||
"ads_billing_service"
|
||||
"ads_tracking_service"
|
||||
"promotion_service"
|
||||
"social_service"
|
||||
"storage_service"
|
||||
"wallet_service"
|
||||
)
|
||||
|
||||
echo "=== GoodGo: Creating databases ==="
|
||||
|
||||
@@ -127,7 +127,7 @@ http:
|
||||
# EN: Inventory Service - Stock Management & Deduction
|
||||
# VI: Inventory Service - Quản lý Tồn kho & Trừ kho
|
||||
inventory-service-router:
|
||||
rule: "PathPrefix(`/api/v1/inventory`)"
|
||||
rule: "PathPrefix(`/api/v1/inventory`) || PathPrefix(`/api/v1/stock`)"
|
||||
service: inventory-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
@@ -136,6 +136,42 @@ http:
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# EN: Wallet Service - Wallet & Payment Management
|
||||
# VI: Wallet Service - Quản lý Ví & Thanh toán
|
||||
wallet-service-router:
|
||||
rule: "PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/payments`) || PathPrefix(`/api/v1/points`)"
|
||||
service: wallet-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
- cors
|
||||
- secure-headers
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# EN: Catalog Service - Product & Category Management
|
||||
# VI: Catalog Service - Quản lý Sản phẩm & Danh mục
|
||||
catalog-service-router:
|
||||
rule: "PathPrefix(`/api/v1/products`) || PathPrefix(`/api/v1/categories`)"
|
||||
service: catalog-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
- cors
|
||||
- secure-headers
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# EN: Booking Service - Booking & Reservation Management
|
||||
# VI: Booking Service - Quản lý Đặt lịch & Đặt chỗ
|
||||
booking-service-router:
|
||||
rule: "PathPrefix(`/api/v1/bookings`) || PathPrefix(`/api/v1/reservations`)"
|
||||
service: booking-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
- cors
|
||||
- secure-headers
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
services:
|
||||
iam-service:
|
||||
loadBalancer:
|
||||
@@ -192,4 +228,25 @@ http:
|
||||
inventory-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://inventory-service-net:8080"
|
||||
- url: "http://inventory-service-net:8080"
|
||||
|
||||
# EN: Wallet Service
|
||||
# VI: Wallet Service
|
||||
wallet-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://wallet-service-net:8080"
|
||||
|
||||
# EN: Catalog Service
|
||||
# VI: Catalog Service
|
||||
catalog-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://catalog-service-net:8080"
|
||||
|
||||
# EN: Booking Service
|
||||
# VI: Booking Service
|
||||
booking-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://booking-service-net:8080"
|
||||
@@ -22,10 +22,73 @@ public class TenantMiddleware
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection)
|
||||
{
|
||||
var shopId = tenantProvider.GetCurrentShopId();
|
||||
var merchantId = tenantProvider.GetCurrentMerchantId();
|
||||
var isServiceCall = tenantProvider.IsServiceCall();
|
||||
var isAdmin = tenantProvider.IsAdmin();
|
||||
|
||||
// EN: Skip tenant SET for service-to-service calls and admin users
|
||||
// VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users
|
||||
if (!isServiceCall && !isAdmin)
|
||||
{
|
||||
if (shopId.HasValue)
|
||||
{
|
||||
await SetTenantContextAsync(dbConnection, shopId.Value, merchantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"EN: No tenant context available for request {Path} / " +
|
||||
"VI: Không có tenant context cho request {Path}",
|
||||
context.Request.Path);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set PostgreSQL session variables for RLS enforcement.
|
||||
/// VI: Đặt biến session PostgreSQL cho RLS enforcement.
|
||||
/// </summary>
|
||||
private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbConnection is NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
if (npgsqlConnection.State != ConnectionState.Open)
|
||||
{
|
||||
await npgsqlConnection.OpenAsync();
|
||||
}
|
||||
|
||||
// EN: Set shop_id as the primary tenant identifier
|
||||
// VI: Đặt shop_id làm tenant identifier chính
|
||||
await using var cmd = npgsqlConnection.CreateCommand();
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " +
|
||||
"VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}",
|
||||
shopId, merchantId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"EN: Failed to set PostgreSQL tenant context / " +
|
||||
"VI: Không thể đặt PostgreSQL tenant context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Asp.Versioning;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using System.Data;
|
||||
using Npgsql;
|
||||
using CatalogService.API.Application.Behaviors;
|
||||
using CatalogService.API.Infrastructure.Tenant;
|
||||
using CatalogService.API.Middleware;
|
||||
@@ -35,6 +37,15 @@ try
|
||||
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
|
||||
builder.Services.AddScoped<ICatalogTenantProvider, CatalogTenantProviderAdapter>();
|
||||
|
||||
// EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS
|
||||
builder.Services.AddTransient<IDbConnection>(sp =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Database connection string is required");
|
||||
return new NpgsqlConnection(connectionString);
|
||||
});
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
|
||||
@@ -99,35 +99,36 @@ public class KitchenTicketServedDomainEventHandler : INotificationHandler<Kitche
|
||||
Reason: $"Auto-deduction for served ticket: {ticket.ItemName} x{ticket.Quantity}",
|
||||
Items: deductionItems);
|
||||
|
||||
// EN: Call inventory service (fire-and-forget style - don't block kitchen flow).
|
||||
// VI: Goi inventory service (kieu fire-and-forget - khong chan luong bep).
|
||||
_ = Task.Run(async () =>
|
||||
// EN: Call inventory service synchronously within the handler.
|
||||
// Polly retry policy on InventoryServiceClient handles transient failures.
|
||||
// If deduction fails, log error but don't throw to avoid blocking kitchen workflow.
|
||||
// VI: Goi inventory service dong bo trong handler.
|
||||
// Polly retry policy tren InventoryServiceClient xu ly loi tam thoi.
|
||||
// Neu tru kho that bai, log loi nhung khong throw de tranh chan luong bep.
|
||||
try
|
||||
{
|
||||
try
|
||||
var result = await _inventoryClient.DeductInventoryAsync(request, cancellationToken);
|
||||
if (result)
|
||||
{
|
||||
var result = await _inventoryClient.DeductInventoryAsync(request, cancellationToken);
|
||||
if (result)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"EN: Inventory deduction successful / VI: Tru kho thanh cong - TicketId: {TicketId}, Items: {ItemCount}",
|
||||
ticket.Id, deductionItems.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"EN: Inventory deduction returned false / VI: Tru kho tra ve false - TicketId: {TicketId}",
|
||||
ticket.Id);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"EN: Inventory deduction successful / VI: Tru kho thanh cong - TicketId: {TicketId}, Items: {ItemCount}",
|
||||
ticket.Id, deductionItems.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
// EN: Log but don't throw - inventory deduction should not block kitchen flow.
|
||||
// VI: Log nhung khong throw - tru kho khong nen chan luong bep.
|
||||
_logger.LogError(ex,
|
||||
"EN: Failed to deduct inventory / VI: Tru kho that bai - TicketId: {TicketId}, Error: {Error}",
|
||||
ticket.Id, ex.Message);
|
||||
_logger.LogWarning(
|
||||
"EN: Inventory deduction returned false / VI: Tru kho tra ve false - TicketId: {TicketId}",
|
||||
ticket.Id);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// EN: Log but don't throw - inventory deduction should not block kitchen flow.
|
||||
// VI: Log nhung khong throw - tru kho khong nen chan luong bep.
|
||||
_logger.LogError(ex,
|
||||
"EN: Failed to deduct inventory / VI: Tru kho that bai - TicketId: {TicketId}, Error: {Error}",
|
||||
ticket.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -22,14 +22,73 @@ public class TenantMiddleware
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection)
|
||||
{
|
||||
// EN: PostgreSQL RLS SET is handled by DbContext interceptor for FnbEngine
|
||||
// since FnbEngine does not register IDbConnection directly.
|
||||
// VI: PostgreSQL RLS SET được xử lý bởi DbContext interceptor cho FnbEngine
|
||||
// vì FnbEngine không đăng ký IDbConnection trực tiếp.
|
||||
var shopId = tenantProvider.GetCurrentShopId();
|
||||
var merchantId = tenantProvider.GetCurrentMerchantId();
|
||||
var isServiceCall = tenantProvider.IsServiceCall();
|
||||
var isAdmin = tenantProvider.IsAdmin();
|
||||
|
||||
// EN: Skip tenant SET for service-to-service calls and admin users
|
||||
// VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users
|
||||
if (!isServiceCall && !isAdmin)
|
||||
{
|
||||
if (shopId.HasValue)
|
||||
{
|
||||
await SetTenantContextAsync(dbConnection, shopId.Value, merchantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"EN: No tenant context available for request {Path} / " +
|
||||
"VI: Không có tenant context cho request {Path}",
|
||||
context.Request.Path);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set PostgreSQL session variables for RLS enforcement.
|
||||
/// VI: Đặt biến session PostgreSQL cho RLS enforcement.
|
||||
/// </summary>
|
||||
private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbConnection is NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
if (npgsqlConnection.State != ConnectionState.Open)
|
||||
{
|
||||
await npgsqlConnection.OpenAsync();
|
||||
}
|
||||
|
||||
// EN: Set shop_id as the primary tenant identifier
|
||||
// VI: Đặt shop_id làm tenant identifier chính
|
||||
await using var cmd = npgsqlConnection.CreateCommand();
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " +
|
||||
"VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}",
|
||||
shopId, merchantId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"EN: Failed to set PostgreSQL tenant context / " +
|
||||
"VI: Không thể đặt PostgreSQL tenant context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using System.Data;
|
||||
using Npgsql;
|
||||
using FnbEngine.API.Application.Behaviors;
|
||||
using FnbEngine.API.Infrastructure.Tenant;
|
||||
using FnbEngine.API.Middleware;
|
||||
@@ -38,6 +40,15 @@ try
|
||||
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
|
||||
builder.Services.AddScoped<IFnbTenantProvider, FnbTenantProviderAdapter>();
|
||||
|
||||
// EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS
|
||||
builder.Services.AddTransient<IDbConnection>(sp =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Database connection string is required");
|
||||
return new NpgsqlConnection(connectionString);
|
||||
});
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
|
||||
@@ -48,14 +48,11 @@ public class KitchenTicket : Entity, IAggregateRoot
|
||||
{
|
||||
}
|
||||
|
||||
public KitchenTicket(Guid sessionId, Guid orderItemId, string itemName, string? station = null, int priority = 0)
|
||||
: this(sessionId, orderItemId, orderItemId, itemName, 1, station, priority)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a kitchen ticket with product ID and quantity.
|
||||
/// VI: Tao phieu bep voi product ID va so luong.
|
||||
/// EN: Create a kitchen ticket with explicit product ID and quantity.
|
||||
/// Product ID is REQUIRED to ensure correct recipe lookup and inventory deduction.
|
||||
/// VI: Tao phieu bep voi product ID tuong minh va so luong.
|
||||
/// Product ID la BAT BUOC de dam bao tra cuu cong thuc va tru kho chinh xac.
|
||||
/// </summary>
|
||||
public KitchenTicket(Guid sessionId, Guid orderItemId, Guid productId, string itemName, int quantity = 1, string? station = null, int priority = 0)
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ public class UpdateTicketStatusCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var ticketId = Guid.NewGuid();
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
_repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ticket);
|
||||
|
||||
@@ -74,7 +74,7 @@ public class UpdateTicketStatusCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var ticketId = Guid.NewGuid();
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
_repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ticket);
|
||||
|
||||
@@ -93,7 +93,7 @@ public class UpdateTicketStatusCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var ticketId = Guid.NewGuid();
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo");
|
||||
_repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ticket);
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ public class KitchenTicketTests
|
||||
private static readonly Guid ValidProductId = Guid.NewGuid();
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithBasicParams_ShouldCreatePendingTicket()
|
||||
public void Constructor_WithAllParams_ShouldCreatePendingTicket()
|
||||
{
|
||||
// Act
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo", "Kitchen", 1);
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo", 1, "Kitchen", 1);
|
||||
|
||||
// Assert
|
||||
ticket.Id.Should().NotBeEmpty();
|
||||
ticket.SessionId.Should().Be(ValidSessionId);
|
||||
ticket.OrderItemId.Should().Be(ValidOrderItemId);
|
||||
ticket.ProductId.Should().Be(ValidOrderItemId); // defaults to orderItemId
|
||||
ticket.ProductId.Should().Be(ValidProductId);
|
||||
ticket.ItemName.Should().Be("Pho Bo");
|
||||
ticket.Station.Should().Be("Kitchen");
|
||||
ticket.Priority.Should().Be(1);
|
||||
@@ -72,7 +72,7 @@ public class KitchenTicketTests
|
||||
public void Constructor_WithNullStation_ShouldAllowNull()
|
||||
{
|
||||
// Act
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Drink");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Drink");
|
||||
|
||||
// Assert
|
||||
ticket.Station.Should().BeNull();
|
||||
@@ -82,7 +82,7 @@ public class KitchenTicketTests
|
||||
public void MarkAsInProgress_ShouldChangeStatusToInProgress()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act
|
||||
ticket.MarkAsInProgress();
|
||||
@@ -96,7 +96,7 @@ public class KitchenTicketTests
|
||||
public void MarkAsReady_ShouldChangeStatusAndSetCompletedAt()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act
|
||||
ticket.MarkAsReady();
|
||||
@@ -111,7 +111,7 @@ public class KitchenTicketTests
|
||||
public void MarkAsServed_ShouldChangeStatusToServed()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act
|
||||
ticket.MarkAsServed();
|
||||
@@ -124,7 +124,7 @@ public class KitchenTicketTests
|
||||
public void MarkAsServed_ShouldRaiseKitchenTicketServedDomainEvent()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act
|
||||
ticket.MarkAsServed();
|
||||
@@ -140,7 +140,7 @@ public class KitchenTicketTests
|
||||
public void MarkAsServed_CalledTwice_ShouldRaiseTwoDomainEvents()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act
|
||||
ticket.MarkAsServed();
|
||||
@@ -154,7 +154,7 @@ public class KitchenTicketTests
|
||||
public void StatusTransition_PendingToInProgressToReadyToServed_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo");
|
||||
var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo");
|
||||
|
||||
// Act & Assert
|
||||
ticket.MarkAsInProgress();
|
||||
|
||||
@@ -22,10 +22,73 @@ public class TenantMiddleware
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection)
|
||||
{
|
||||
var shopId = tenantProvider.GetCurrentShopId();
|
||||
var merchantId = tenantProvider.GetCurrentMerchantId();
|
||||
var isServiceCall = tenantProvider.IsServiceCall();
|
||||
var isAdmin = tenantProvider.IsAdmin();
|
||||
|
||||
// EN: Skip tenant SET for service-to-service calls and admin users
|
||||
// VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users
|
||||
if (!isServiceCall && !isAdmin)
|
||||
{
|
||||
if (shopId.HasValue)
|
||||
{
|
||||
await SetTenantContextAsync(dbConnection, shopId.Value, merchantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"EN: No tenant context available for request {Path} / " +
|
||||
"VI: Không có tenant context cho request {Path}",
|
||||
context.Request.Path);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set PostgreSQL session variables for RLS enforcement.
|
||||
/// VI: Đặt biến session PostgreSQL cho RLS enforcement.
|
||||
/// </summary>
|
||||
private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbConnection is NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
if (npgsqlConnection.State != ConnectionState.Open)
|
||||
{
|
||||
await npgsqlConnection.OpenAsync();
|
||||
}
|
||||
|
||||
// EN: Set shop_id as the primary tenant identifier
|
||||
// VI: Đặt shop_id làm tenant identifier chính
|
||||
await using var cmd = npgsqlConnection.CreateCommand();
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " +
|
||||
"VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}",
|
||||
shopId, merchantId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"EN: Failed to set PostgreSQL tenant context / " +
|
||||
"VI: Không thể đặt PostgreSQL tenant context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using System.Data;
|
||||
using Npgsql;
|
||||
using InventoryService.API.Application.Behaviors;
|
||||
using InventoryService.API.Infrastructure.Tenant;
|
||||
using InventoryService.API.Middleware;
|
||||
@@ -34,6 +36,15 @@ try
|
||||
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
|
||||
builder.Services.AddScoped<IInventoryTenantProvider, InventoryTenantProviderAdapter>();
|
||||
|
||||
// EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS
|
||||
builder.Services.AddTransient<IDbConnection>(sp =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Database connection string is required");
|
||||
return new NpgsqlConnection(connectionString);
|
||||
});
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
|
||||
@@ -100,6 +100,10 @@ public class PosHub : Hub<IPosHubClient>
|
||||
var userId = Context.UserIdentifier
|
||||
?? throw new HubException("User not authenticated / User chưa xác thực");
|
||||
|
||||
// EN: Validate that the authenticated user has access to the requested shop.
|
||||
// VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu.
|
||||
ValidateShopAccess(shopId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: User {UserId} joining shop {ShopId} / " +
|
||||
"VI: User {UserId} tham gia shop {ShopId}",
|
||||
@@ -119,6 +123,10 @@ public class PosHub : Hub<IPosHubClient>
|
||||
var userId = Context.UserIdentifier
|
||||
?? throw new HubException("User not authenticated / User chưa xác thực");
|
||||
|
||||
// EN: Validate that the authenticated user has access to the requested shop.
|
||||
// VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu.
|
||||
ValidateShopAccess(shopId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: User {UserId} joining KDS for shop {ShopId} / " +
|
||||
"VI: User {UserId} tham gia KDS cho shop {ShopId}",
|
||||
@@ -138,6 +146,10 @@ public class PosHub : Hub<IPosHubClient>
|
||||
var userId = Context.UserIdentifier
|
||||
?? throw new HubException("User not authenticated / User chưa xác thực");
|
||||
|
||||
// EN: Validate that the authenticated user has access to the requested shop.
|
||||
// VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu.
|
||||
ValidateShopAccess(shopId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: User {UserId} joining POS terminal for shop {ShopId} / " +
|
||||
"VI: User {UserId} tham gia thiết bị POS cho shop {ShopId}",
|
||||
@@ -200,4 +212,27 @@ public class PosHub : Hub<IPosHubClient>
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Authorization Helpers
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate that the authenticated user has access to the specified shop.
|
||||
/// Prevents cross-tenant access where a user from shop A joins shop B's group.
|
||||
/// VI: Xác thực rằng user đã xác thực có quyền truy cập shop được chỉ định.
|
||||
/// Ngăn chặn truy cập cross-tenant khi user từ shop A tham gia group của shop B.
|
||||
/// </summary>
|
||||
private void ValidateShopAccess(Guid shopId)
|
||||
{
|
||||
var userShopClaim = Context.User?.FindFirst("shop_id")?.Value;
|
||||
if (string.IsNullOrEmpty(userShopClaim) || !Guid.TryParse(userShopClaim, out var userShopId) || userShopId != shopId)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"EN: Unauthorized shop access attempt - User tried to join shop {RequestedShopId} but belongs to shop {UserShopId} / " +
|
||||
"VI: Truy cập shop trái phép - User cố tham gia shop {RequestedShopId} nhưng thuộc shop {UserShopId}",
|
||||
shopId, userShopClaim ?? "none");
|
||||
throw new HubException("Unauthorized: You don't have access to this shop / Không có quyền: Bạn không có quyền truy cập shop này");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -71,12 +71,12 @@ public class TenantMiddleware
|
||||
// EN: Set shop_id as the primary tenant identifier
|
||||
// VI: Đặt shop_id làm tenant identifier chính
|
||||
await using var cmd = npgsqlConnection.CreateCommand();
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId}'";
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value}'";
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,73 @@ public class TenantMiddleware
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
|
||||
public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection)
|
||||
{
|
||||
var shopId = tenantProvider.GetCurrentShopId();
|
||||
var merchantId = tenantProvider.GetCurrentMerchantId();
|
||||
var isServiceCall = tenantProvider.IsServiceCall();
|
||||
var isAdmin = tenantProvider.IsAdmin();
|
||||
|
||||
// EN: Skip tenant SET for service-to-service calls and admin users
|
||||
// VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users
|
||||
if (!isServiceCall && !isAdmin)
|
||||
{
|
||||
if (shopId.HasValue)
|
||||
{
|
||||
await SetTenantContextAsync(dbConnection, shopId.Value, merchantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"EN: No tenant context available for request {Path} / " +
|
||||
"VI: Không có tenant context cho request {Path}",
|
||||
context.Request.Path);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set PostgreSQL session variables for RLS enforcement.
|
||||
/// VI: Đặt biến session PostgreSQL cho RLS enforcement.
|
||||
/// </summary>
|
||||
private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbConnection is NpgsqlConnection npgsqlConnection)
|
||||
{
|
||||
if (npgsqlConnection.State != ConnectionState.Open)
|
||||
{
|
||||
await npgsqlConnection.OpenAsync();
|
||||
}
|
||||
|
||||
// EN: Set shop_id as the primary tenant identifier
|
||||
// VI: Đặt shop_id làm tenant identifier chính
|
||||
await using var cmd = npgsqlConnection.CreateCommand();
|
||||
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " +
|
||||
"VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}",
|
||||
shopId, merchantId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"EN: Failed to set PostgreSQL tenant context / " +
|
||||
"VI: Không thể đặt PostgreSQL tenant context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,8 @@ using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using WalletService.API.Application.Behaviors;
|
||||
using System.Data;
|
||||
using Npgsql;
|
||||
using WalletService.API.Infrastructure.Tenant;
|
||||
using WalletService.API.Middleware;
|
||||
using WalletService.Infrastructure;
|
||||
@@ -36,6 +38,15 @@ try
|
||||
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
|
||||
builder.Services.AddScoped<IWalletTenantProvider, WalletTenantProviderAdapter>();
|
||||
|
||||
// EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS
|
||||
builder.Services.AddTransient<IDbConnection>(sp =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Database connection string is required");
|
||||
return new NpgsqlConnection(connectionString);
|
||||
});
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
|
||||
@@ -66,20 +66,9 @@ public class PaymentEntityTypeConfiguration : IEntityTypeConfiguration<Payment>
|
||||
builder.Property<DateTime?>("_completedAt")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
// EN: Ignore computed properties and domain events
|
||||
// VI: Bo qua cac property tinh toan va domain events
|
||||
builder.Ignore(p => p.OrderId);
|
||||
builder.Ignore(p => p.Amount);
|
||||
builder.Ignore(p => p.Currency);
|
||||
builder.Ignore(p => p.GatewayName);
|
||||
builder.Ignore(p => p.TransactionId);
|
||||
builder.Ignore(p => p.PaymentUrl);
|
||||
builder.Ignore(p => p.StatusId);
|
||||
// EN: Ignore computed properties and domain events (NOT expression-bodied properties backed by mapped fields).
|
||||
// VI: Bo qua cac property tinh toan va domain events (KHONG ignore cac property co backing field da map).
|
||||
builder.Ignore(p => p.Status);
|
||||
builder.Ignore(p => p.ErrorCode);
|
||||
builder.Ignore(p => p.ErrorMessage);
|
||||
builder.Ignore(p => p.CreatedAt);
|
||||
builder.Ignore(p => p.CompletedAt);
|
||||
builder.Ignore(p => p.DomainEvents);
|
||||
|
||||
// EN: Indexes for common queries
|
||||
|
||||
Reference in New Issue
Block a user