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:
Ho Ngoc Hai
2026-03-06 16:22:08 +07:00
parent 7f8709ac9f
commit 653322b26c
21 changed files with 469 additions and 87 deletions

View File

@@ -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();
}
}

View File

@@ -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");
}

View File

@@ -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
{

View File

@@ -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

View File

@@ -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 ==="

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 =>
{

View File

@@ -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)
{

View File

@@ -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>

View File

@@ -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 =>
{

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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();

View File

@@ -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>

View File

@@ -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 =>
{

View File

@@ -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
}

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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 =>
{

View File

@@ -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