From 227266d183f61f97385af0f91f6a69f60bf5d688 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Tue, 10 Mar 2026 10:03:32 +0800
Subject: [PATCH] feat: add mini ordering catalog and order APIs
---
.../Controllers/CatalogController.cs | 113 ++
.../Controllers/OrdersController.cs | 87 +
.../appsettings.Development.json | 20 +-
.../appsettings.Production.json | 20 +-
.../App/Mini/Contracts/MiniCategoryDto.cs | 12 +
.../MiniCheckoutValidationRequest.cs | 12 +
.../MiniCheckoutValidationResponse.cs | 10 +
.../Mini/Contracts/MiniCreateOrderRequest.cs | 14 +
.../Mini/Contracts/MiniCreateOrderResponse.cs | 15 +
.../App/Mini/Contracts/MiniMenuSectionDto.cs | 30 +
.../App/Mini/Contracts/MiniMockPayResponse.cs | 14 +
.../App/Mini/Contracts/MiniOrderDetailDto.cs | 59 +
.../App/Mini/Contracts/MiniOrderLineInput.cs | 12 +
.../App/Mini/Contracts/MiniOrderSummaryDto.cs | 19 +
.../Contracts/MiniPriceEstimateRequest.cs | 12 +
.../Contracts/MiniPriceEstimateResponse.cs | 21 +
.../Mini/Contracts/MiniProductDetailDto.cs | 69 +
.../App/Mini/Contracts/MiniStoreSummaryDto.cs | 14 +
.../App/Mini/IMiniAppService.cs | 50 +
.../Orders/Entities/Order.cs | 92 ++
.../Orders/Entities/OrderItem.cs | 50 +
.../Orders/Entities/OrderStatusHistory.cs | 31 +
.../Orders/Enums/DeliveryType.cs | 20 +
.../Orders/Enums/OrderChannel.cs | 20 +
.../Orders/Enums/OrderStatus.cs | 32 +
.../Orders/Repositories/IOrderRepository.cs | 30 +
.../Payments/Enums/PaymentStatus.cs | 28 +
.../AppServiceCollectionExtensions.cs | 24 +-
.../App/MiniAppService.cs | 1402 +++++++++++++++++
.../App/Persistence/MiniOrderDbContext.cs | 64 +
.../App/Repositories/EfOrderRepository.cs | 52 +
.../App/Tenancy/DatabaseTenantCodeResolver.cs | 44 +
.../TakeoutSaaS.Infrastructure.csproj | 10 +-
33 files changed, 2483 insertions(+), 19 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs
create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs
diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs
new file mode 100644
index 0000000..8045186
--- /dev/null
+++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/CatalogController.cs
@@ -0,0 +1,113 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Mini;
+using TakeoutSaaS.Application.App.Mini.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.MiniApi.Controllers;
+
+///
+/// 小程序商品与门店查询接口。
+///
+[ApiVersion("1.0")]
+[Route("api/mini/v{version:apiVersion}")]
+public sealed class CatalogController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController
+{
+ ///
+ /// 获取当前租户下可用门店列表。
+ ///
+ /// 请求取消令牌。
+ /// 门店摘要列表。
+ [HttpGet("stores")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetStoresAsync(CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetStoresAsync(tenantProvider.GetCurrentTenantId(), cancellationToken);
+ return ApiResponse>.Ok(data);
+ }
+
+ ///
+ /// 获取指定门店在当前履约场景下的商品分类。
+ ///
+ /// 门店编号。
+ /// 履约场景。
+ /// 访问渠道。
+ /// 请求取消令牌。
+ /// 分类列表。
+ [HttpGet("categories")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetCategoriesAsync([FromQuery] string storeId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetCategoriesAsync(tenantProvider.GetCurrentTenantId(), storeId, scene, channel, cancellationToken);
+ return ApiResponse>.Ok(data);
+ }
+
+ ///
+ /// 获取指定门店的菜单分组与商品列表。
+ ///
+ /// 门店编号。
+ /// 履约场景。
+ /// 访问渠道。
+ /// 请求取消令牌。
+ /// 菜单分组列表。
+ [HttpGet("menus/{storeId}")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetMenuAsync([FromRoute] string storeId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetMenuAsync(tenantProvider.GetCurrentTenantId(), storeId, scene, channel, cancellationToken);
+ return ApiResponse>.Ok(data);
+ }
+
+ ///
+ /// 获取商品详情与可选规格信息。
+ ///
+ /// 商品编号。
+ /// 履约场景。
+ /// 访问渠道。
+ /// 请求取消令牌。
+ /// 商品详情。
+ [HttpGet("products/{productId}")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetProductDetailAsync([FromRoute] string productId, [FromQuery] string scene, [FromQuery] string channel, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetProductDetailAsync(tenantProvider.GetCurrentTenantId(), productId, scene, channel, cancellationToken);
+ return data == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(data);
+ }
+
+ ///
+ /// 试算购物车金额。
+ ///
+ /// 试算请求。
+ /// 请求取消令牌。
+ /// 金额试算结果。
+ [HttpPost("products/price-estimate")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> EstimatePriceAsync([FromBody] MiniPriceEstimateRequest request, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.EstimatePriceAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken);
+ return ApiResponse.Ok(data);
+ }
+
+ ///
+ /// 下单前校验商品与价格是否有效。
+ ///
+ /// 结算校验请求。
+ /// 请求取消令牌。
+ /// 结算校验结果。
+ [HttpPost("products/checkout-validate")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> CheckoutValidateAsync([FromBody] MiniCheckoutValidationRequest request, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.ValidateCheckoutAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken);
+ return ApiResponse.Ok(data);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs
new file mode 100644
index 0000000..fe3c853
--- /dev/null
+++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/OrdersController.cs
@@ -0,0 +1,87 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Mini;
+using TakeoutSaaS.Application.App.Mini.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.MiniApi.Controllers;
+
+///
+/// 小程序订单接口。
+///
+[ApiVersion("1.0")]
+[Route("api/mini/v{version:apiVersion}/orders")]
+public sealed class OrdersController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController
+{
+ ///
+ /// 获取当前顾客的订单列表。
+ ///
+ /// 请求取消令牌。
+ /// 订单摘要列表。
+ [HttpGet]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetOrdersAsync(CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetOrdersAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerPhone(), cancellationToken);
+ return ApiResponse>.Ok(data);
+ }
+
+ ///
+ /// 获取指定订单详情。
+ ///
+ /// 订单编号。
+ /// 请求取消令牌。
+ /// 订单详情。
+ [HttpGet("{orderId}")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetOrderDetailAsync([FromRoute] string orderId, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.GetOrderDetailAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken);
+ return data == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(data);
+ }
+
+ ///
+ /// 创建订单。
+ ///
+ /// 下单请求。
+ /// 请求取消令牌。
+ /// 创建结果。
+ [HttpPost]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> CreateOrderAsync([FromBody] MiniCreateOrderRequest request, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.CreateOrderAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerName(), ResolveCustomerPhone(), request, cancellationToken);
+ return ApiResponse.Ok(data);
+ }
+
+ ///
+ /// 模拟支付指定订单。
+ ///
+ /// 订单编号。
+ /// 请求取消令牌。
+ /// 模拟支付结果。
+ [HttpPost("{orderId}/mock-pay")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> MockPayAsync([FromRoute] string orderId, CancellationToken cancellationToken)
+ {
+ var data = await miniAppService.MockPayAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken);
+ return ApiResponse.Ok(data);
+ }
+
+ private string ResolveCustomerPhone()
+ {
+ return Request.Headers.TryGetValue("X-Mini-Customer-Phone", out var phone) ? phone.FirstOrDefault()?.Trim() ?? string.Empty : string.Empty;
+ }
+
+ private string ResolveCustomerName()
+ {
+ return Request.Headers.TryGetValue("X-Mini-Customer-Name", out var name) ? name.FirstOrDefault()?.Trim() ?? string.Empty : string.Empty;
+ }
+}
diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json
index 5433dca..f7faaa4 100644
--- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json
+++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json
@@ -1,9 +1,17 @@
{
- "Cors": {
- "Mini": []
+ "Database": {
+ "DataSources": {
+ "AppDatabase": {
+ "Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
+ "Reads": [
+ "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
+ ],
+ "CommandTimeoutSeconds": 30,
+ "MaxRetryCount": 3,
+ "MaxRetryDelaySeconds": 5
+ }
+ }
},
- "Otel": {
- "Endpoint": "",
- "UseConsoleExporter": true
- }
+ "Cors": { "Mini": [] },
+ "Otel": { "Endpoint": "", "UseConsoleExporter": true }
}
diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json
index 94a5d9a..19586d1 100644
--- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json
+++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json
@@ -1,9 +1,17 @@
{
- "Cors": {
- "Mini": []
+ "Database": {
+ "DataSources": {
+ "AppDatabase": {
+ "Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
+ "Reads": [
+ "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
+ ],
+ "CommandTimeoutSeconds": 30,
+ "MaxRetryCount": 3,
+ "MaxRetryDelaySeconds": 5
+ }
+ }
},
- "Otel": {
- "Endpoint": "",
- "UseConsoleExporter": false
- }
+ "Cors": { "Mini": [] },
+ "Otel": { "Endpoint": "", "UseConsoleExporter": false }
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs
new file mode 100644
index 0000000..1b889ba
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCategoryDto.cs
@@ -0,0 +1,12 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 分类摘要。
+///
+public sealed class MiniCategoryDto
+{
+ public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public int Sort { get; init; }
+ public int ProductCount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs
new file mode 100644
index 0000000..5ed00f8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationRequest.cs
@@ -0,0 +1,12 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 结算校验请求。
+///
+public sealed class MiniCheckoutValidationRequest
+{
+ public string StoreId { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public string Channel { get; init; } = string.Empty;
+ public IReadOnlyList Items { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs
new file mode 100644
index 0000000..f356760
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCheckoutValidationResponse.cs
@@ -0,0 +1,10 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 结算校验结果。
+///
+public sealed class MiniCheckoutValidationResponse
+{
+ public bool Valid { get; init; }
+ public string Message { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs
new file mode 100644
index 0000000..3590849
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderRequest.cs
@@ -0,0 +1,14 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 创建订单请求。
+///
+public sealed class MiniCreateOrderRequest
+{
+ public string StoreId { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public string Channel { get; init; } = string.Empty;
+ public string? Remark { get; init; }
+ public string? TableNo { get; init; }
+ public IReadOnlyList Items { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs
new file mode 100644
index 0000000..b045d31
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniCreateOrderResponse.cs
@@ -0,0 +1,15 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 创建订单结果。
+///
+public sealed class MiniCreateOrderResponse
+{
+ public string OrderId { get; init; } = string.Empty;
+ public string OrderNo { get; init; } = string.Empty;
+ public string StatusText { get; init; } = string.Empty;
+ public string PaymentStatusText { get; init; } = string.Empty;
+ public decimal PayableAmount { get; init; }
+ public string PayableAmountText { get; init; } = "0.00";
+ public bool MockPayAvailable { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs
new file mode 100644
index 0000000..124432a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMenuSectionDto.cs
@@ -0,0 +1,30 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 菜单分组。
+///
+public sealed class MiniMenuSectionDto
+{
+ public string CategoryId { get; init; } = string.Empty;
+ public string CategoryName { get; init; } = string.Empty;
+ public IReadOnlyList Products { get; init; } = [];
+
+ ///
+ /// 菜单商品卡片。
+ ///
+ public sealed class MiniMenuProductDto
+ {
+ public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public string Description { get; init; } = string.Empty;
+ public string CoverImageUrl { get; init; } = string.Empty;
+ public decimal Price { get; init; }
+ public string PriceText { get; init; } = "0.00";
+ public decimal? OriginalPrice { get; init; }
+ public string? OriginalPriceText { get; init; }
+ public string SalesText { get; init; } = string.Empty;
+ public IReadOnlyList TagTexts { get; init; } = [];
+ public bool SoldOut { get; init; }
+ public bool HasOptions { get; init; }
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs
new file mode 100644
index 0000000..9657ea4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniMockPayResponse.cs
@@ -0,0 +1,14 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 模拟支付结果。
+///
+public sealed class MiniMockPayResponse
+{
+ public string OrderId { get; init; } = string.Empty;
+ public string StatusText { get; init; } = string.Empty;
+ public string PaymentStatusText { get; init; } = string.Empty;
+ public decimal PaidAmount { get; init; }
+ public string PaidAmountText { get; init; } = "0.00";
+ public string SuccessTip { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs
new file mode 100644
index 0000000..ac6b165
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderDetailDto.cs
@@ -0,0 +1,59 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 订单详情。
+///
+public sealed class MiniOrderDetailDto
+{
+ public string Id { get; init; } = string.Empty;
+ public string OrderNo { get; init; } = string.Empty;
+ public string StoreId { get; init; } = string.Empty;
+ public string StoreName { get; init; } = string.Empty;
+ public string StatusText { get; init; } = string.Empty;
+ public string PaymentStatusText { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public string CustomerName { get; init; } = string.Empty;
+ public string CustomerPhone { get; init; } = string.Empty;
+ public string TableNo { get; init; } = string.Empty;
+ public string Remark { get; init; } = string.Empty;
+ public decimal ItemsAmount { get; init; }
+ public decimal PackagingFee { get; init; }
+ public decimal DeliveryFee { get; init; }
+ public decimal DiscountAmount { get; init; }
+ public decimal PayableAmount { get; init; }
+ public decimal PaidAmount { get; init; }
+ public string ItemSummary { get; init; } = string.Empty;
+ public string CreatedAt { get; init; } = string.Empty;
+ public string? PaidAt { get; init; }
+ public string ActionText { get; init; } = string.Empty;
+ public IReadOnlyList Items { get; init; } = [];
+ public IReadOnlyList Timeline { get; init; } = [];
+
+ ///
+ /// 订单商品明细。
+ ///
+ public sealed class MiniOrderItemDto
+ {
+ public string Id { get; init; } = string.Empty;
+ public string ProductId { get; init; } = string.Empty;
+ public string ProductName { get; init; } = string.Empty;
+ public string SkuName { get; init; } = string.Empty;
+ public string Unit { get; init; } = string.Empty;
+ public int Quantity { get; init; }
+ public decimal UnitPrice { get; init; }
+ public string UnitPriceText { get; init; } = "0.00";
+ public decimal SubTotal { get; init; }
+ public string SubTotalText { get; init; } = "0.00";
+ }
+
+ ///
+ /// 订单时间线。
+ ///
+ public sealed class MiniOrderTimelineDto
+ {
+ public int Status { get; init; }
+ public string StatusText { get; init; } = string.Empty;
+ public string Notes { get; init; } = string.Empty;
+ public string OccurredAt { get; init; } = string.Empty;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs
new file mode 100644
index 0000000..9df45c3
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderLineInput.cs
@@ -0,0 +1,12 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 结算行项目输入。
+///
+public sealed class MiniOrderLineInput
+{
+ public string ProductId { get; init; } = string.Empty;
+ public string? SkuId { get; init; }
+ public int Quantity { get; init; }
+ public IReadOnlyList AddonItemIds { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs
new file mode 100644
index 0000000..f4382e2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniOrderSummaryDto.cs
@@ -0,0 +1,19 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 订单摘要。
+///
+public sealed class MiniOrderSummaryDto
+{
+ public string Id { get; init; } = string.Empty;
+ public string OrderNo { get; init; } = string.Empty;
+ public string StoreName { get; init; } = string.Empty;
+ public string StatusText { get; init; } = string.Empty;
+ public string PaymentStatusText { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public string ItemSummary { get; init; } = string.Empty;
+ public decimal TotalAmount { get; init; }
+ public string TotalAmountText { get; init; } = "0.00";
+ public string CreatedAt { get; init; } = string.Empty;
+ public string ActionText { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs
new file mode 100644
index 0000000..ba64d08
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateRequest.cs
@@ -0,0 +1,12 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 金额试算请求。
+///
+public sealed class MiniPriceEstimateRequest
+{
+ public string StoreId { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public string Channel { get; init; } = string.Empty;
+ public IReadOnlyList Items { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs
new file mode 100644
index 0000000..1a23708
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniPriceEstimateResponse.cs
@@ -0,0 +1,21 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 金额试算结果。
+///
+public sealed class MiniPriceEstimateResponse
+{
+ public string StoreId { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public int TotalCount { get; init; }
+ public decimal OriginalAmount { get; init; }
+ public string OriginalAmountText { get; init; } = "0.00";
+ public decimal PackagingFee { get; init; }
+ public string PackagingFeeText { get; init; } = "0.00";
+ public decimal DeliveryFee { get; init; }
+ public string DeliveryFeeText { get; init; } = "0.00";
+ public decimal DiscountAmount { get; init; }
+ public string DiscountAmountText { get; init; } = "0.00";
+ public decimal PayableAmount { get; init; }
+ public string PayableAmountText { get; init; } = "0.00";
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs
new file mode 100644
index 0000000..a7a7e92
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniProductDetailDto.cs
@@ -0,0 +1,69 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 商品详情。
+///
+public sealed class MiniProductDetailDto
+{
+ public string Id { get; init; } = string.Empty;
+ public string StoreId { get; init; } = string.Empty;
+ public string CategoryId { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public string Subtitle { get; init; } = string.Empty;
+ public string Description { get; init; } = string.Empty;
+ public string CoverImageUrl { get; init; } = string.Empty;
+ public IReadOnlyList GalleryImages { get; init; } = [];
+ public string Unit { get; init; } = string.Empty;
+ public decimal BasePrice { get; init; }
+ public string BasePriceText { get; init; } = "0.00";
+ public decimal? OriginalPrice { get; init; }
+ public string? OriginalPriceText { get; init; }
+ public bool SoldOut { get; init; }
+ public int MonthlySales { get; init; }
+ public IReadOnlyList TagTexts { get; init; } = [];
+ public string? DefaultSkuId { get; init; }
+ public IReadOnlyList Skus { get; init; } = [];
+ public IReadOnlyList OptionGroups { get; init; } = [];
+
+ ///
+ /// 商品 SKU。
+ ///
+ public sealed class MiniProductSkuDto
+ {
+ public string Id { get; init; } = string.Empty;
+ public decimal Price { get; init; }
+ public string PriceText { get; init; } = "0.00";
+ public decimal? OriginalPrice { get; init; }
+ public string? OriginalPriceText { get; init; }
+ public int? StockQuantity { get; init; }
+ public bool SoldOut { get; init; }
+ public IReadOnlyList SelectedOptionIds { get; init; } = [];
+ }
+
+ ///
+ /// 商品选项分组。
+ ///
+ public sealed class MiniProductOptionGroupDto
+ {
+ public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public string GroupType { get; init; } = string.Empty;
+ public string SelectionType { get; init; } = string.Empty;
+ public bool Required { get; init; }
+ public int MinSelect { get; init; }
+ public int MaxSelect { get; init; }
+ public IReadOnlyList Options { get; init; } = [];
+ }
+
+ ///
+ /// 商品选项。
+ ///
+ public sealed class MiniProductOptionDto
+ {
+ public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public decimal ExtraPrice { get; init; }
+ public string ExtraPriceText { get; init; } = "0.00";
+ public bool SoldOut { get; init; }
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs
new file mode 100644
index 0000000..42b4341
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/Contracts/MiniStoreSummaryDto.cs
@@ -0,0 +1,14 @@
+namespace TakeoutSaaS.Application.App.Mini.Contracts;
+
+///
+/// 门店摘要。
+///
+public sealed class MiniStoreSummaryDto
+{
+ public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
+ public string Address { get; init; } = string.Empty;
+ public string BusinessHours { get; init; } = string.Empty;
+ public IReadOnlyList Supports { get; init; } = [];
+ public IReadOnlyList TagTexts { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs b/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs
new file mode 100644
index 0000000..384af22
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Mini/IMiniAppService.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Application.App.Mini.Contracts;
+
+namespace TakeoutSaaS.Application.App.Mini;
+
+///
+/// 小程序真实业务服务抽象。
+///
+public interface IMiniAppService
+{
+ ///
+ /// 查询可选门店。
+ ///
+ Task> GetStoresAsync(long tenantId, CancellationToken cancellationToken = default);
+ ///
+ /// 查询门店分类。
+ ///
+ Task> GetCategoriesAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default);
+ ///
+ /// 查询门店菜单。
+ ///
+ Task> GetMenuAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default);
+ ///
+ /// 查询商品详情。
+ ///
+ Task GetProductDetailAsync(long tenantId, string productId, string scene, string channel, CancellationToken cancellationToken = default);
+ ///
+ /// 试算订单金额。
+ ///
+ Task EstimatePriceAsync(long tenantId, MiniPriceEstimateRequest request, CancellationToken cancellationToken = default);
+ ///
+ /// 校验结算请求。
+ ///
+ Task ValidateCheckoutAsync(long tenantId, MiniCheckoutValidationRequest request, CancellationToken cancellationToken = default);
+ ///
+ /// 查询顾客订单列表。
+ ///
+ Task> GetOrdersAsync(long tenantId, string customerPhone, CancellationToken cancellationToken = default);
+ ///
+ /// 查询顾客订单详情。
+ ///
+ Task GetOrderDetailAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default);
+ ///
+ /// 创建订单。
+ ///
+ Task CreateOrderAsync(long tenantId, string customerName, string customerPhone, MiniCreateOrderRequest request, CancellationToken cancellationToken = default);
+ ///
+ /// 模拟支付。
+ ///
+ Task MockPayAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs
new file mode 100644
index 0000000..ec8e326
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs
@@ -0,0 +1,92 @@
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Orders.Entities;
+
+///
+/// 小程序订单聚合根。
+///
+public sealed class Order : MultiTenantEntityBase
+{
+ ///
+ /// 订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+ ///
+ /// 下单渠道。
+ ///
+ public OrderChannel Channel { get; set; } = OrderChannel.MiniProgram;
+ ///
+ /// 履约方式。
+ ///
+ public DeliveryType DeliveryType { get; set; } = DeliveryType.Delivery;
+ ///
+ /// 订单状态。
+ ///
+ public OrderStatus Status { get; set; } = OrderStatus.PendingPayment;
+ ///
+ /// 支付状态。
+ ///
+ public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Unpaid;
+ ///
+ /// 顾客姓名。
+ ///
+ public string? CustomerName { get; set; }
+ ///
+ /// 顾客手机号。
+ ///
+ public string? CustomerPhone { get; set; }
+ ///
+ /// 桌号。
+ ///
+ public string? TableNo { get; set; }
+ ///
+ /// 排队号。
+ ///
+ public string? QueueNumber { get; set; }
+ ///
+ /// 预约 ID。
+ ///
+ public long? ReservationId { get; set; }
+ ///
+ /// 商品原价合计。
+ ///
+ public decimal ItemsAmount { get; set; }
+ ///
+ /// 优惠金额。
+ ///
+ public decimal DiscountAmount { get; set; }
+ ///
+ /// 应付金额。
+ ///
+ public decimal PayableAmount { get; set; }
+ ///
+ /// 实付金额。
+ ///
+ public decimal PaidAmount { get; set; }
+ ///
+ /// 支付时间。
+ ///
+ public DateTime? PaidAt { get; set; }
+ ///
+ /// 完成时间。
+ ///
+ public DateTime? FinishedAt { get; set; }
+ ///
+ /// 取消时间。
+ ///
+ public DateTime? CancelledAt { get; set; }
+ ///
+ /// 取消原因。
+ ///
+ public string? CancelReason { get; set; }
+ ///
+ /// 订单备注。
+ ///
+ public string? Remark { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs
new file mode 100644
index 0000000..ce59115
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Orders.Entities;
+
+///
+/// 订单明细。
+///
+public sealed class OrderItem : MultiTenantEntityBase
+{
+ ///
+ /// 订单 ID。
+ ///
+ public long OrderId { get; set; }
+ ///
+ /// 商品 ID。
+ ///
+ public long ProductId { get; set; }
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+ ///
+ /// SKU 名称。
+ ///
+ public string? SkuName { get; set; }
+ ///
+ /// 单位。
+ ///
+ public string? Unit { get; set; }
+ ///
+ /// 数量。
+ ///
+ public int Quantity { get; set; }
+ ///
+ /// 单价。
+ ///
+ public decimal UnitPrice { get; set; }
+ ///
+ /// 优惠金额。
+ ///
+ public decimal DiscountAmount { get; set; }
+ ///
+ /// 小计。
+ ///
+ public decimal SubTotal { get; set; }
+ ///
+ /// 属性 JSON。
+ ///
+ public string? AttributesJson { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs
new file mode 100644
index 0000000..cdca57b
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs
@@ -0,0 +1,31 @@
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Orders.Entities;
+
+///
+/// 订单状态流转记录。
+///
+public sealed class OrderStatusHistory : MultiTenantEntityBase
+{
+ ///
+ /// 订单 ID。
+ ///
+ public long OrderId { get; set; }
+ ///
+ /// 变更后的状态。
+ ///
+ public OrderStatus Status { get; set; }
+ ///
+ /// 操作人 ID。
+ ///
+ public long? OperatorId { get; set; }
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; set; }
+ ///
+ /// 发生时间。
+ ///
+ public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs
new file mode 100644
index 0000000..a288f70
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs
@@ -0,0 +1,20 @@
+namespace TakeoutSaaS.Domain.Orders.Enums;
+
+///
+/// 履约方式。
+///
+public enum DeliveryType
+{
+ ///
+ /// 堂食。
+ ///
+ DineIn = 0,
+ ///
+ /// 自提。
+ ///
+ Pickup = 1,
+ ///
+ /// 配送。
+ ///
+ Delivery = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs
new file mode 100644
index 0000000..1593295
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs
@@ -0,0 +1,20 @@
+namespace TakeoutSaaS.Domain.Orders.Enums;
+
+///
+/// 下单渠道。
+///
+public enum OrderChannel
+{
+ ///
+ /// 未知渠道。
+ ///
+ Unknown = 0,
+ ///
+ /// 小程序。
+ ///
+ MiniProgram = 1,
+ ///
+ /// 扫码点餐。
+ ///
+ ScanToOrder = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs
new file mode 100644
index 0000000..14a6e4b
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs
@@ -0,0 +1,32 @@
+namespace TakeoutSaaS.Domain.Orders.Enums;
+
+///
+/// 订单状态。
+///
+public enum OrderStatus
+{
+ ///
+ /// 待付款。
+ ///
+ PendingPayment = 0,
+ ///
+ /// 已付款待制作。
+ ///
+ AwaitingPreparation = 1,
+ ///
+ /// 制作中。
+ ///
+ InProgress = 2,
+ ///
+ /// 待取餐或待送达。
+ ///
+ Ready = 3,
+ ///
+ /// 已完成。
+ ///
+ Completed = 4,
+ ///
+ /// 已取消。
+ ///
+ Cancelled = 5
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs
new file mode 100644
index 0000000..fb865b4
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs
@@ -0,0 +1,30 @@
+using TakeoutSaaS.Domain.Orders.Entities;
+
+namespace TakeoutSaaS.Domain.Orders.Repositories;
+
+///
+/// 订单写仓储。
+///
+public interface IOrderRepository
+{
+ ///
+ /// 新增订单。
+ ///
+ Task AddAsync(Order order, CancellationToken cancellationToken = default);
+ ///
+ /// 批量新增订单明细。
+ ///
+ Task AddItemsAsync(IReadOnlyCollection items, CancellationToken cancellationToken = default);
+ ///
+ /// 新增状态流转记录。
+ ///
+ Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default);
+ ///
+ /// 查询可写订单。
+ ///
+ Task FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default);
+ ///
+ /// 保存变更。
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs
new file mode 100644
index 0000000..199af3c
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs
@@ -0,0 +1,28 @@
+namespace TakeoutSaaS.Domain.Payments.Enums;
+
+///
+/// 支付状态。
+///
+public enum PaymentStatus
+{
+ ///
+ /// 未支付。
+ ///
+ Unpaid = 0,
+ ///
+ /// 支付中。
+ ///
+ Paying = 1,
+ ///
+ /// 已支付。
+ ///
+ Paid = 2,
+ ///
+ /// 支付失败。
+ ///
+ Failed = 3,
+ ///
+ /// 已退款。
+ ///
+ Refunded = 4
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 1b46430..5485f5a 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -1,5 +1,12 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Application.App.Mini;
+using TakeoutSaaS.Domain.Orders.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Infrastructure.App.Repositories;
+using TakeoutSaaS.Infrastructure.App.Tenancy;
+using TakeoutSaaS.Module.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Extensions;
@@ -9,14 +16,21 @@ namespace TakeoutSaaS.Infrastructure.App.Extensions;
public static class AppServiceCollectionExtensions
{
///
- /// 注册骨架所需的基础设施能力。
+ /// 注册小程序端基础设施能力。
///
- /// 服务集合。
- /// 配置源。
- /// 服务集合。
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
- _ = configuration;
+ var writeConnectionString = configuration["Database:DataSources:AppDatabase:Write"];
+ if (string.IsNullOrWhiteSpace(writeConnectionString))
+ {
+ throw new InvalidOperationException("缺少 Database:DataSources:AppDatabase:Write 配置。");
+ }
+
+ services.AddDbContext(options => options.UseNpgsql(writeConnectionString));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
return services;
}
}
+
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs
new file mode 100644
index 0000000..1fd93e7
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/MiniAppService.cs
@@ -0,0 +1,1402 @@
+using Microsoft.Extensions.Configuration;
+using System.Globalization;
+using System.Text.Json;
+using Dapper;
+using Npgsql;
+using TakeoutSaaS.Application.App.Mini;
+using TakeoutSaaS.Application.App.Mini.Contracts;
+using TakeoutSaaS.Domain.Orders.Entities;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Orders.Repositories;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Infrastructure.App;
+
+///
+/// 小程序端真实业务服务实现。
+///
+public sealed class MiniAppService(
+ IConfiguration configuration,
+ IOrderRepository orderRepository,
+ IIdGenerator idGenerator) : IMiniAppService
+{
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web);
+
+ ///
+ public async Task> GetStoresAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+
+ // 1. 查询有效门店并按可售商品数倒排。
+ await using var connection = OpenReadConnection();
+ const string sql = """
+select s."Id",
+ s."Name",
+ coalesce(s."Address", '') as "Address",
+ coalesce(s."BusinessHours", '商家营业中') as "BusinessHours",
+ coalesce(s."Tags", '') as "Tags",
+ s."SupportsDelivery",
+ s."SupportsPickup",
+ s."SupportsDineIn",
+ (
+ select count(1)
+ from public.products p
+ where p."TenantId" = s."TenantId"
+ and p."StoreId" = s."Id"
+ and p."DeletedAt" is null
+ and p."Status" = 1
+ ) as "ProductCount"
+from public.stores s
+where s."TenantId" = @TenantId
+ and s."DeletedAt" is null
+ and s."Status" = 2
+ and s."BusinessStatus" = 1
+order by "ProductCount" desc, s."CreatedAt" asc, s."Id" asc;
+""";
+ var rows = (await connection.QueryAsync(new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))).ToList();
+
+ // 2. 映射门店展示模型。
+ return rows.Select(row => new MiniStoreSummaryDto
+ {
+ Id = row.Id.ToString(CultureInfo.InvariantCulture),
+ Name = row.Name,
+ Address = row.Address,
+ BusinessHours = row.BusinessHours,
+ Supports = BuildSupports(row),
+ TagTexts = SplitTexts(row.Tags)
+ }).ToList();
+ }
+
+ ///
+ public async Task> GetCategoriesAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ var storeIdValue = ParseId(storeId, nameof(storeId));
+ var sceneSpec = ParseScene(scene);
+ _ = channel;
+
+ // 1. 查询当前场景下有商品的分类。
+ await using var connection = OpenReadConnection();
+ var sql = $"""
+select c."Id",
+ c."Name",
+ c."SortOrder" as "Sort",
+ count(p."Id")::int as "ProductCount"
+from public.product_categories c
+left join public.products p
+ on p."CategoryId" = c."Id"
+ and p."TenantId" = c."TenantId"
+ and p."StoreId" = c."StoreId"
+ and p."DeletedAt" is null
+ and p."Status" = 1
+ and p.{sceneSpec.ProductEnabledColumn} = true
+where c."TenantId" = @TenantId
+ and c."StoreId" = @StoreId
+ and c."DeletedAt" is null
+ and c."IsEnabled" = true
+ and (c."ChannelsJson" is null or c."ChannelsJson" = '' or c."ChannelsJson" like @ChannelPattern)
+group by c."Id", c."Name", c."SortOrder"
+having count(p."Id") > 0
+order by c."SortOrder" asc, c."Id" asc;
+""";
+ var rows = (await connection.QueryAsync(new CommandDefinition(
+ sql,
+ new { TenantId = tenantId, StoreId = storeIdValue, ChannelPattern = $"%\"{sceneSpec.CategoryChannelToken}\"%" },
+ cancellationToken: cancellationToken))).ToList();
+
+ return rows;
+ }
+
+ ///
+ public async Task> GetMenuAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ var storeIdValue = ParseId(storeId, nameof(storeId));
+ var sceneSpec = ParseScene(scene);
+ _ = channel;
+
+ // 1. 查询当前场景下的分类与商品。
+ await using var connection = OpenReadConnection();
+ var sql = $"""
+select c."Id" as "CategoryId",
+ c."Name" as "CategoryName",
+ p."Id",
+ p."Name",
+ coalesce(nullif(p."Subtitle", ''), coalesce(p."Description", '')) as "Description",
+ coalesce(p."CoverImage", '') as "CoverImageUrl",
+ coalesce(
+ (
+ select min(sku."Price")
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ ),
+ p."Price") as "DisplayPrice",
+ coalesce(
+ (
+ select min(sku."OriginalPrice")
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ and sku."OriginalPrice" is not null
+ ),
+ p."OriginalPrice") as "DisplayOriginalPrice",
+ coalesce(p."SalesMonthly", 0) as "SalesMonthly",
+ coalesce(p."TagsJson", '[]') as "TagsJson",
+ exists(
+ select 1
+ from public.product_spec_template_products link
+ join public.product_spec_templates template on template."Id" = link."TemplateId"
+ where link."ProductId" = p."Id"
+ and link."DeletedAt" is null
+ and template."DeletedAt" is null
+ and template."IsEnabled" = true
+ ) as "HasOptions",
+ case
+ when exists(
+ select 1
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ and coalesce(sku."StockQuantity", 999999) > 0
+ ) then false
+ when exists(
+ select 1
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ ) then true
+ else coalesce(p."RemainStock", p."StockQuantity", 999999) <= 0
+ end as "SoldOut"
+from public.product_categories c
+join public.products p
+ on p."CategoryId" = c."Id"
+ and p."TenantId" = c."TenantId"
+ and p."StoreId" = c."StoreId"
+ and p."DeletedAt" is null
+ and p."Status" = 1
+ and p.{sceneSpec.ProductEnabledColumn} = true
+where c."TenantId" = @TenantId
+ and c."StoreId" = @StoreId
+ and c."DeletedAt" is null
+ and c."IsEnabled" = true
+ and (c."ChannelsJson" is null or c."ChannelsJson" = '' or c."ChannelsJson" like @ChannelPattern)
+order by c."SortOrder" asc, p."SortWeight" desc, p."CreatedAt" asc, p."Id" asc;
+""";
+ var rows = (await connection.QueryAsync(new CommandDefinition(
+ sql,
+ new { TenantId = tenantId, StoreId = storeIdValue, ChannelPattern = $"%\"{sceneSpec.CategoryChannelToken}\"%" },
+ cancellationToken: cancellationToken))).ToList();
+
+ // 2. 按分类聚合菜单。
+ return rows
+ .GroupBy(row => new { row.CategoryId, row.CategoryName })
+ .Select(group => new MiniMenuSectionDto
+ {
+ CategoryId = group.Key.CategoryId.ToString(CultureInfo.InvariantCulture),
+ CategoryName = group.Key.CategoryName,
+ Products = group.Select(row => new MiniMenuSectionDto.MiniMenuProductDto
+ {
+ Id = row.Id.ToString(CultureInfo.InvariantCulture),
+ Name = row.Name,
+ Description = row.Description,
+ CoverImageUrl = row.CoverImageUrl,
+ Price = row.DisplayPrice,
+ PriceText = FormatMoney(row.DisplayPrice),
+ OriginalPrice = row.DisplayOriginalPrice,
+ OriginalPriceText = row.DisplayOriginalPrice.HasValue ? FormatMoney(row.DisplayOriginalPrice.Value) : null,
+ SalesText = $"月售 {row.SalesMonthly}",
+ TagTexts = SplitTexts(row.TagsJson),
+ SoldOut = row.SoldOut,
+ HasOptions = row.HasOptions
+ }).ToList()
+ })
+ .ToList();
+ }
+
+ ///
+ public async Task GetProductDetailAsync(long tenantId, string productId, string scene, string channel, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ var productIdValue = ParseId(productId, nameof(productId));
+ var sceneSpec = ParseScene(scene);
+ _ = channel;
+
+ // 1. 查询商品主体。
+ await using var connection = OpenReadConnection();
+ var productSql = $"""
+select p."Id",
+ p."StoreId",
+ p."CategoryId",
+ p."Name",
+ coalesce(p."Subtitle", '') as "Subtitle",
+ coalesce(p."Description", '') as "Description",
+ coalesce(p."CoverImage", '') as "CoverImageUrl",
+ coalesce(p."GalleryImages", '[]') as "GalleryImages",
+ coalesce(p."Unit", '份') as "Unit",
+ p."Price",
+ p."OriginalPrice",
+ coalesce(p."SalesMonthly", 0) as "MonthlySales",
+ coalesce(p."TagsJson", '[]') as "TagsJson",
+ coalesce(p."PackingFee", 0) as "PackingFee",
+ coalesce(p."RemainStock", p."StockQuantity", 999999) as "StockQuantity",
+ exists(
+ select 1
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ ) as "HasEnabledSkus"
+from public.products p
+where p."TenantId" = @TenantId
+ and p."Id" = @ProductId
+ and p."DeletedAt" is null
+ and p."Status" = 1
+ and p.{sceneSpec.ProductEnabledColumn} = true
+limit 1;
+""";
+ var product = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(
+ productSql,
+ new { TenantId = tenantId, ProductId = productIdValue },
+ cancellationToken: cancellationToken));
+
+ if (product == null)
+ {
+ return null;
+ }
+
+ // 2. 查询商品关联的选项模板与选项。
+ const string optionSql = """
+select template."Id" as "TemplateId",
+ template."Name" as "TemplateName",
+ template."TemplateType",
+ template."SelectionType",
+ template."IsRequired",
+ template."MinSelect",
+ template."MaxSelect",
+ template."SortOrder" as "TemplateSortOrder",
+ option."Id" as "OptionId",
+ option."Name" as "OptionName",
+ option."ExtraPrice",
+ option."Stock",
+ option."SortOrder" as "OptionSortOrder"
+from public.product_spec_template_products link
+join public.product_spec_templates template
+ on template."Id" = link."TemplateId"
+ and template."DeletedAt" is null
+ and template."IsEnabled" = true
+join public.product_spec_template_options option
+ on option."TemplateId" = template."Id"
+ and option."DeletedAt" is null
+ and option."IsEnabled" = true
+where link."TenantId" = @TenantId
+ and link."ProductId" = @ProductId
+ and link."DeletedAt" is null
+order by template."SortOrder" asc, template."Id" asc, option."SortOrder" asc, option."Id" asc;
+""";
+ var optionRows = (await connection.QueryAsync(new CommandDefinition(
+ optionSql,
+ new { TenantId = tenantId, ProductId = productIdValue },
+ cancellationToken: cancellationToken))).ToList();
+
+ // 3. 查询 SKU 列表。
+ const string skuSql = """
+select sku."Id",
+ sku."Price",
+ sku."OriginalPrice",
+ sku."StockQuantity",
+ sku."AttributesJson",
+ sku."IsEnabled"
+from public.product_skus sku
+where sku."ProductId" = @ProductId
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+order by sku."SortOrder" asc, sku."Id" asc;
+""";
+ var skuRows = (await connection.QueryAsync(new CommandDefinition(
+ skuSql,
+ new { ProductId = productIdValue },
+ cancellationToken: cancellationToken))).ToList();
+
+ // 4. 组装详情模型。
+ var optionGroups = optionRows
+ .GroupBy(row => new { row.TemplateId, row.TemplateName, row.TemplateType, row.SelectionType, row.IsRequired, row.MinSelect, row.MaxSelect, row.TemplateSortOrder })
+ .OrderBy(group => group.Key.TemplateSortOrder)
+ .Select(group => new MiniProductDetailDto.MiniProductOptionGroupDto
+ {
+ Id = group.Key.TemplateId.ToString(CultureInfo.InvariantCulture),
+ Name = group.Key.TemplateName,
+ GroupType = MapGroupType(group.Key.TemplateType),
+ SelectionType = MapSelectionType(group.Key.SelectionType),
+ Required = group.Key.IsRequired,
+ MinSelect = group.Key.MinSelect,
+ MaxSelect = group.Key.MaxSelect,
+ Options = group.Select(row => new MiniProductDetailDto.MiniProductOptionDto
+ {
+ Id = row.OptionId.ToString(CultureInfo.InvariantCulture),
+ Name = row.OptionName,
+ ExtraPrice = row.ExtraPrice,
+ ExtraPriceText = FormatMoney(row.ExtraPrice),
+ SoldOut = row.Stock <= 0
+ }).ToList()
+ })
+ .ToList();
+
+ var skus = skuRows.Select(row => new MiniProductDetailDto.MiniProductSkuDto
+ {
+ Id = row.Id.ToString(CultureInfo.InvariantCulture),
+ Price = row.Price,
+ PriceText = FormatMoney(row.Price),
+ OriginalPrice = row.OriginalPrice,
+ OriginalPriceText = row.OriginalPrice.HasValue ? FormatMoney(row.OriginalPrice.Value) : null,
+ StockQuantity = row.StockQuantity,
+ SoldOut = row.StockQuantity.HasValue && row.StockQuantity.Value <= 0,
+ SelectedOptionIds = ParseSkuAttributeOptionIds(row.AttributesJson).Select(id => id.ToString(CultureInfo.InvariantCulture)).ToList()
+ }).ToList();
+
+ return new MiniProductDetailDto
+ {
+ Id = product.Id.ToString(CultureInfo.InvariantCulture),
+ StoreId = product.StoreId.ToString(CultureInfo.InvariantCulture),
+ CategoryId = product.CategoryId.ToString(CultureInfo.InvariantCulture),
+ Name = product.Name,
+ Subtitle = product.Subtitle,
+ Description = product.Description,
+ CoverImageUrl = product.CoverImageUrl,
+ GalleryImages = SplitTexts(product.GalleryImages),
+ Unit = product.Unit,
+ BasePrice = product.Price,
+ BasePriceText = FormatMoney(product.Price),
+ OriginalPrice = product.OriginalPrice,
+ OriginalPriceText = product.OriginalPrice.HasValue ? FormatMoney(product.OriginalPrice.Value) : null,
+ SoldOut = skus.Count > 0 ? skus.All(item => item.SoldOut) : (product.StockQuantity.HasValue && product.StockQuantity.Value <= 0),
+ MonthlySales = product.MonthlySales,
+ TagTexts = SplitTexts(product.TagsJson),
+ DefaultSkuId = skus.FirstOrDefault(item => !item.SoldOut)?.Id ?? skus.FirstOrDefault()?.Id,
+ Skus = skus,
+ OptionGroups = optionGroups
+ };
+ }
+
+ ///
+ public async Task EstimatePriceAsync(long tenantId, MiniPriceEstimateRequest request, CancellationToken cancellationToken = default)
+ {
+ var draft = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken);
+ return BuildEstimateResponse(draft);
+ }
+
+ ///
+ public async Task ValidateCheckoutAsync(long tenantId, MiniCheckoutValidationRequest request, CancellationToken cancellationToken = default)
+ {
+ _ = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken);
+ return new MiniCheckoutValidationResponse
+ {
+ Valid = true,
+ Message = "校验通过"
+ };
+ }
+
+ ///
+ public async Task> GetOrdersAsync(long tenantId, string customerPhone, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ if (string.IsNullOrWhiteSpace(customerPhone))
+ {
+ return [];
+ }
+
+ // 1. 查询顾客名下订单。
+ await using var connection = OpenReadConnection();
+ const string orderSql = """
+select o."Id",
+ o."OrderNo",
+ o."StoreId",
+ s."Name" as "StoreName",
+ o."Status",
+ o."PaymentStatus",
+ o."DeliveryType",
+ o."PayableAmount",
+ o."CreatedAt"
+from public.orders o
+join public.stores s on s."Id" = o."StoreId"
+where o."TenantId" = @TenantId
+ and o."DeletedAt" is null
+ and o."CustomerPhone" = @CustomerPhone
+order by o."CreatedAt" desc, o."Id" desc
+limit 100;
+""";
+ var orderRows = (await connection.QueryAsync(new CommandDefinition(
+ orderSql,
+ new { TenantId = tenantId, CustomerPhone = customerPhone.Trim() },
+ cancellationToken: cancellationToken))).ToList();
+
+ if (orderRows.Count == 0)
+ {
+ return [];
+ }
+
+ // 2. 查询订单商品,用于拼接摘要。
+ var orderIds = orderRows.Select(row => row.Id).ToArray();
+ const string itemSql = """
+select "Id", "OrderId", "ProductId", "ProductName", coalesce("SkuName", '') as "SkuName", coalesce("Unit", '') as "Unit", "Quantity", "UnitPrice", "SubTotal"
+from public.order_items
+where "TenantId" = @TenantId
+ and "DeletedAt" is null
+ and "OrderId" = any(@OrderIds)
+order by "OrderId" asc, "Id" asc;
+""";
+ var itemRows = (await connection.QueryAsync(new CommandDefinition(
+ itemSql,
+ new { TenantId = tenantId, OrderIds = orderIds },
+ cancellationToken: cancellationToken))).ToList();
+ var itemLookup = itemRows.GroupBy(row => row.OrderId).ToDictionary(group => group.Key, group => group.ToList());
+
+ return orderRows.Select(row => new MiniOrderSummaryDto
+ {
+ Id = row.Id.ToString(CultureInfo.InvariantCulture),
+ OrderNo = row.OrderNo,
+ StoreName = row.StoreName,
+ StatusText = MapOrderStatusText(row.Status),
+ PaymentStatusText = MapPaymentStatusText(row.PaymentStatus),
+ Scene = MapSceneText(row.DeliveryType),
+ ItemSummary = BuildItemSummary(itemLookup.GetValueOrDefault(row.Id) ?? []),
+ TotalAmount = row.PayableAmount,
+ TotalAmountText = FormatMoney(row.PayableAmount),
+ CreatedAt = FormatDateTime(row.CreatedAt),
+ ActionText = MapActionText(row.Status, row.PaymentStatus)
+ }).ToList();
+ }
+
+ ///
+ public async Task GetOrderDetailAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ if (string.IsNullOrWhiteSpace(customerPhone))
+ {
+ return null;
+ }
+
+ var orderIdValue = ParseId(orderId, nameof(orderId));
+ await using var connection = OpenReadConnection();
+ const string orderSql = """
+select o."Id",
+ o."OrderNo",
+ o."StoreId",
+ s."Name" as "StoreName",
+ o."Status",
+ o."PaymentStatus",
+ o."DeliveryType",
+ coalesce(o."CustomerName", '') as "CustomerName",
+ coalesce(o."CustomerPhone", '') as "CustomerPhone",
+ coalesce(o."TableNo", '') as "TableNo",
+ coalesce(o."Remark", '') as "Remark",
+ o."ItemsAmount",
+ o."DiscountAmount",
+ o."PayableAmount",
+ o."PaidAmount",
+ o."CreatedAt",
+ o."PaidAt"
+from public.orders o
+join public.stores s on s."Id" = o."StoreId"
+where o."TenantId" = @TenantId
+ and o."DeletedAt" is null
+ and o."Id" = @OrderId
+ and o."CustomerPhone" = @CustomerPhone
+limit 1;
+""";
+ var orderRow = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(
+ orderSql,
+ new { TenantId = tenantId, OrderId = orderIdValue, CustomerPhone = customerPhone.Trim() },
+ cancellationToken: cancellationToken));
+
+ if (orderRow == null)
+ {
+ return null;
+ }
+
+ const string itemSql = """
+select "Id", "OrderId", "ProductId", "ProductName", coalesce("SkuName", '') as "SkuName", coalesce("Unit", '') as "Unit", "Quantity", "UnitPrice", "SubTotal"
+from public.order_items
+where "TenantId" = @TenantId
+ and "DeletedAt" is null
+ and "OrderId" = @OrderId
+order by "Id" asc;
+""";
+ var itemRows = (await connection.QueryAsync(new CommandDefinition(
+ itemSql,
+ new { TenantId = tenantId, OrderId = orderIdValue },
+ cancellationToken: cancellationToken))).ToList();
+
+ const string historySql = """
+select "Status", coalesce("Notes", '') as "Notes", "OccurredAt"
+from public.order_status_histories
+where "TenantId" = @TenantId
+ and "DeletedAt" is null
+ and "OrderId" = @OrderId
+order by "OccurredAt" asc, "Id" asc;
+""";
+ var historyRows = (await connection.QueryAsync(new CommandDefinition(
+ historySql,
+ new { TenantId = tenantId, OrderId = orderIdValue },
+ cancellationToken: cancellationToken))).ToList();
+
+ var extraFee = Math.Max(orderRow.PayableAmount - orderRow.ItemsAmount + orderRow.DiscountAmount, 0);
+ var packagingFee = orderRow.DeliveryType == (int)DeliveryType.Delivery ? 0 : extraFee;
+ var deliveryFee = orderRow.DeliveryType == (int)DeliveryType.Delivery ? extraFee : 0;
+
+ return new MiniOrderDetailDto
+ {
+ Id = orderRow.Id.ToString(CultureInfo.InvariantCulture),
+ OrderNo = orderRow.OrderNo,
+ StoreId = orderRow.StoreId.ToString(CultureInfo.InvariantCulture),
+ StoreName = orderRow.StoreName,
+ StatusText = MapOrderStatusText(orderRow.Status),
+ PaymentStatusText = MapPaymentStatusText(orderRow.PaymentStatus),
+ Scene = MapSceneText(orderRow.DeliveryType),
+ CustomerName = orderRow.CustomerName,
+ CustomerPhone = orderRow.CustomerPhone,
+ TableNo = orderRow.TableNo,
+ Remark = orderRow.Remark,
+ ItemsAmount = orderRow.ItemsAmount,
+ PackagingFee = packagingFee,
+ DeliveryFee = deliveryFee,
+ DiscountAmount = orderRow.DiscountAmount,
+ PayableAmount = orderRow.PayableAmount,
+ PaidAmount = orderRow.PaidAmount,
+ ItemSummary = BuildItemSummary(itemRows),
+ CreatedAt = FormatDateTime(orderRow.CreatedAt),
+ PaidAt = orderRow.PaidAt.HasValue ? FormatDateTime(orderRow.PaidAt.Value) : null,
+ ActionText = MapActionText(orderRow.Status, orderRow.PaymentStatus),
+ Items = itemRows.Select(row => new MiniOrderDetailDto.MiniOrderItemDto
+ {
+ Id = row.Id.ToString(CultureInfo.InvariantCulture),
+ ProductId = row.ProductId.ToString(CultureInfo.InvariantCulture),
+ ProductName = row.ProductName,
+ SkuName = row.SkuName,
+ Unit = row.Unit,
+ Quantity = row.Quantity,
+ UnitPrice = row.UnitPrice,
+ UnitPriceText = FormatMoney(row.UnitPrice),
+ SubTotal = row.SubTotal,
+ SubTotalText = FormatMoney(row.SubTotal)
+ }).ToList(),
+ Timeline = historyRows.Select(row => new MiniOrderDetailDto.MiniOrderTimelineDto
+ {
+ Status = row.Status,
+ StatusText = MapOrderStatusText(row.Status),
+ Notes = string.IsNullOrWhiteSpace(row.Notes) ? MapOrderStatusText(row.Status) : row.Notes,
+ OccurredAt = FormatDateTime(row.OccurredAt)
+ }).ToList()
+ };
+ }
+
+ ///
+ public async Task CreateOrderAsync(long tenantId, string customerName, string customerPhone, MiniCreateOrderRequest request, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ if (string.IsNullOrWhiteSpace(customerPhone))
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, "缺少顾客手机号");
+ }
+
+ // 1. 先复用试算逻辑生成订单草稿。
+ var draft = await BuildDraftAsync(tenantId, request.StoreId, request.Scene, request.Items, cancellationToken);
+ var orderId = idGenerator.NextId();
+ var now = DateTime.UtcNow;
+ var resolvedCustomerName = string.IsNullOrWhiteSpace(customerName) ? "临时顾客" : customerName.Trim();
+
+ // 2. 写入订单主表。
+ var order = new Order
+ {
+ Id = orderId,
+ TenantId = tenantId,
+ OrderNo = $"T{orderId}",
+ StoreId = draft.StoreId,
+ Channel = OrderChannel.MiniProgram,
+ DeliveryType = draft.DeliveryType,
+ Status = OrderStatus.PendingPayment,
+ PaymentStatus = PaymentStatus.Unpaid,
+ CustomerName = resolvedCustomerName,
+ CustomerPhone = customerPhone.Trim(),
+ TableNo = request.Scene.Equals("DineIn", StringComparison.OrdinalIgnoreCase) ? (request.TableNo ?? string.Empty).Trim() : null,
+ ItemsAmount = draft.OriginalAmount,
+ DiscountAmount = draft.DiscountAmount,
+ PayableAmount = draft.PayableAmount,
+ PaidAmount = 0,
+ Remark = request.Remark?.Trim(),
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+ await orderRepository.AddAsync(order, cancellationToken);
+
+ // 3. 写入订单明细。
+ var orderItems = draft.Items.Select(item => new OrderItem
+ {
+ Id = idGenerator.NextId(),
+ TenantId = tenantId,
+ OrderId = orderId,
+ ProductId = item.ProductId,
+ ProductName = item.ProductName,
+ SkuName = item.SkuName,
+ Unit = item.Unit,
+ Quantity = item.Quantity,
+ UnitPrice = item.UnitPrice,
+ DiscountAmount = item.DiscountAmount,
+ SubTotal = item.SubTotal,
+ AttributesJson = item.AttributesJson,
+ CreatedAt = now,
+ UpdatedAt = now
+ }).ToList();
+ await orderRepository.AddItemsAsync(orderItems, cancellationToken);
+
+ // 4. 写入初始状态流转。
+ await orderRepository.AddStatusHistoryAsync(new OrderStatusHistory
+ {
+ Id = idGenerator.NextId(),
+ TenantId = tenantId,
+ OrderId = orderId,
+ Status = OrderStatus.PendingPayment,
+ Notes = "下单",
+ OccurredAt = now,
+ CreatedAt = now,
+ UpdatedAt = now
+ }, cancellationToken);
+
+ await orderRepository.SaveChangesAsync(cancellationToken);
+
+ return new MiniCreateOrderResponse
+ {
+ OrderId = orderId.ToString(CultureInfo.InvariantCulture),
+ OrderNo = order.OrderNo,
+ StatusText = MapOrderStatusText((int)OrderStatus.PendingPayment),
+ PaymentStatusText = MapPaymentStatusText((int)PaymentStatus.Unpaid),
+ PayableAmount = draft.PayableAmount,
+ PayableAmountText = FormatMoney(draft.PayableAmount),
+ MockPayAvailable = true
+ };
+ }
+
+ ///
+ public async Task MockPayAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default)
+ {
+ EnsureTenantResolved(tenantId);
+ if (string.IsNullOrWhiteSpace(customerPhone))
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, "缺少顾客手机号");
+ }
+
+ // 1. 校验订单归属并查询可写实体。
+ var orderIdValue = ParseId(orderId, nameof(orderId));
+ var order = await orderRepository.FindAsync(tenantId, orderIdValue, cancellationToken);
+ if (order == null || !string.Equals(order.CustomerPhone, customerPhone.Trim(), StringComparison.Ordinal))
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "订单不存在");
+ }
+
+ if (order.PaymentStatus != PaymentStatus.Paid)
+ {
+ // 2. 更新支付状态与订单状态。
+ var now = DateTime.UtcNow;
+ order.PaymentStatus = PaymentStatus.Paid;
+ order.Status = OrderStatus.AwaitingPreparation;
+ order.PaidAmount = order.PayableAmount;
+ order.PaidAt = now;
+ order.UpdatedAt = now;
+
+ await orderRepository.AddStatusHistoryAsync(new OrderStatusHistory
+ {
+ Id = idGenerator.NextId(),
+ TenantId = tenantId,
+ OrderId = order.Id,
+ Status = OrderStatus.AwaitingPreparation,
+ Notes = "支付成功",
+ OccurredAt = now,
+ CreatedAt = now,
+ UpdatedAt = now
+ }, cancellationToken);
+
+ await orderRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ return new MiniMockPayResponse
+ {
+ OrderId = order.Id.ToString(CultureInfo.InvariantCulture),
+ StatusText = MapOrderStatusText((int)order.Status),
+ PaymentStatusText = MapPaymentStatusText((int)order.PaymentStatus),
+ PaidAmount = order.PaidAmount,
+ PaidAmountText = FormatMoney(order.PaidAmount),
+ SuccessTip = "模拟支付成功,可在订单列表继续查看履约状态。"
+ };
+ }
+
+ private async Task BuildDraftAsync(
+ long tenantId,
+ string storeId,
+ string scene,
+ IReadOnlyList items,
+ CancellationToken cancellationToken)
+ {
+ EnsureTenantResolved(tenantId);
+ if (items.Count == 0)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, "购物车不能为空");
+ }
+
+ var storeIdValue = ParseId(storeId, nameof(storeId));
+ var sceneSpec = ParseScene(scene);
+
+ // 1. 校验门店存在。
+ await using var connection = OpenReadConnection();
+ const string storeSql = """
+select "Id", "Name"
+from public.stores
+where "TenantId" = @TenantId
+ and "Id" = @StoreId
+ and "DeletedAt" is null
+ and "Status" = 2
+ and "BusinessStatus" = 1
+limit 1;
+""";
+ var store = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(
+ storeSql,
+ new { TenantId = tenantId, StoreId = storeIdValue },
+ cancellationToken: cancellationToken));
+
+ if (store == null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "门店不存在或不可用");
+ }
+
+ var lineItems = new List();
+ var totalCount = 0;
+ decimal originalAmount = 0;
+ decimal packagingFee = 0;
+ decimal discountAmount = 0;
+
+ // 2. 按购物车行逐条校验并生成价格草稿。
+ foreach (var item in items)
+ {
+ if (item.Quantity <= 0)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, "商品数量必须大于 0");
+ }
+
+ var productIdValue = ParseId(item.ProductId, nameof(item.ProductId));
+ var productSql = $"""
+select p."Id",
+ p."StoreId",
+ p."CategoryId",
+ p."Name",
+ coalesce(p."Subtitle", '') as "Subtitle",
+ coalesce(p."Description", '') as "Description",
+ coalesce(p."CoverImage", '') as "CoverImageUrl",
+ coalesce(p."GalleryImages", '[]') as "GalleryImages",
+ coalesce(p."Unit", '份') as "Unit",
+ p."Price",
+ p."OriginalPrice",
+ coalesce(p."SalesMonthly", 0) as "MonthlySales",
+ coalesce(p."TagsJson", '[]') as "TagsJson",
+ coalesce(p."PackingFee", 0) as "PackingFee",
+ coalesce(p."RemainStock", p."StockQuantity", 999999) as "StockQuantity",
+ exists(
+ select 1
+ from public.product_skus sku
+ where sku."ProductId" = p."Id"
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+ ) as "HasEnabledSkus"
+from public.products p
+where p."TenantId" = @TenantId
+ and p."StoreId" = @StoreId
+ and p."Id" = @ProductId
+ and p."DeletedAt" is null
+ and p."Status" = 1
+ and p.{sceneSpec.ProductEnabledColumn} = true
+limit 1;
+""";
+ var product = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(
+ productSql,
+ new { TenantId = tenantId, StoreId = storeIdValue, ProductId = productIdValue },
+ cancellationToken: cancellationToken));
+
+ if (product == null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "商品不存在或当前场景不可售");
+ }
+
+ if (product.HasEnabledSkus && string.IsNullOrWhiteSpace(item.SkuId))
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】请先选择规格");
+ }
+
+ var selectedSkuOptionIds = new List();
+ decimal unitPrice = product.Price;
+ decimal unitOriginalPrice = product.OriginalPrice ?? product.Price;
+ var skuName = string.Empty;
+
+ // 3. 解析 SKU。
+ if (!string.IsNullOrWhiteSpace(item.SkuId))
+ {
+ var skuIdValue = ParseId(item.SkuId, nameof(item.SkuId));
+ const string skuSql = """
+select sku."Id", sku."Price", sku."OriginalPrice", sku."StockQuantity", sku."AttributesJson", sku."IsEnabled"
+from public.product_skus sku
+where sku."ProductId" = @ProductId
+ and sku."Id" = @SkuId
+ and sku."DeletedAt" is null
+ and sku."IsEnabled" = true
+limit 1;
+""";
+ var sku = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(
+ skuSql,
+ new { ProductId = productIdValue, SkuId = skuIdValue },
+ cancellationToken: cancellationToken));
+
+ if (sku == null)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】规格不存在");
+ }
+
+ if (sku.StockQuantity.HasValue && sku.StockQuantity.Value <= 0)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】规格库存不足");
+ }
+
+ unitPrice = sku.Price;
+ unitOriginalPrice = sku.OriginalPrice ?? product.OriginalPrice ?? sku.Price;
+ selectedSkuOptionIds = ParseSkuAttributeOptionIds(sku.AttributesJson);
+
+ if (selectedSkuOptionIds.Count > 0)
+ {
+ const string skuOptionSql = """
+select "Id", "Name"
+from public.product_spec_template_options
+where "DeletedAt" is null
+ and "Id" = any(@OptionIds)
+order by "Id" asc;
+""";
+ var skuOptionRows = (await connection.QueryAsync(new CommandDefinition(
+ skuOptionSql,
+ new { OptionIds = selectedSkuOptionIds.ToArray() },
+ cancellationToken: cancellationToken))).ToList();
+ skuName = string.Join("/", skuOptionRows.Select(row => row.Name));
+ }
+ }
+ else if (product.StockQuantity.HasValue && product.StockQuantity.Value <= 0)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】库存不足");
+ }
+
+ // 4. 解析加料模板与加料项。
+ const string addonTemplateSql = """
+select template."Id" as "TemplateId",
+ template."Name" as "TemplateName",
+ template."IsRequired",
+ template."MinSelect",
+ template."MaxSelect",
+ template."SelectionType"
+from public.product_spec_template_products link
+join public.product_spec_templates template
+ on template."Id" = link."TemplateId"
+ and template."DeletedAt" is null
+ and template."IsEnabled" = true
+ and template."TemplateType" = 2
+where link."TenantId" = @TenantId
+ and link."ProductId" = @ProductId
+ and link."DeletedAt" is null
+order by template."SortOrder" asc, template."Id" asc;
+""";
+ var addonTemplates = (await connection.QueryAsync(new CommandDefinition(
+ addonTemplateSql,
+ new { TenantId = tenantId, ProductId = productIdValue },
+ cancellationToken: cancellationToken))).ToList();
+
+ var addonIds = item.AddonItemIds
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Select(value => ParseId(value, nameof(item.AddonItemIds)))
+ .Distinct()
+ .ToArray();
+
+ var addonRows = addonIds.Length == 0
+ ? []
+ : (await connection.QueryAsync(new CommandDefinition(
+ """
+select distinct template."Id" as "TemplateId",
+ option."Id",
+ option."Name",
+ option."ExtraPrice",
+ option."Stock"
+from public.product_spec_template_products link
+join public.product_spec_templates template
+ on template."Id" = link."TemplateId"
+ and template."DeletedAt" is null
+ and template."IsEnabled" = true
+ and template."TemplateType" = 2
+join public.product_spec_template_options option
+ on option."TemplateId" = template."Id"
+ and option."DeletedAt" is null
+ and option."IsEnabled" = true
+where link."TenantId" = @TenantId
+ and link."ProductId" = @ProductId
+ and link."DeletedAt" is null
+ and option."Id" = any(@OptionIds)
+order by template."Id" asc, option."Id" asc;
+""",
+ new { TenantId = tenantId, ProductId = productIdValue, OptionIds = addonIds },
+ cancellationToken: cancellationToken))).ToList();
+
+ if (addonRows.Count != addonIds.Length)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{product.Name}】存在非法加料选项");
+ }
+
+ ValidateAddonSelections(product.Name, addonTemplates, addonRows);
+
+ var addonPrice = addonRows.Sum(row => row.ExtraPrice);
+ var addonNames = addonRows.Select(row => row.Name).ToList();
+ var actualUnitPrice = unitPrice + addonPrice;
+ var actualOriginalUnitPrice = unitOriginalPrice + addonPrice;
+ var lineOriginalAmount = actualOriginalUnitPrice * item.Quantity;
+ var lineActualAmount = actualUnitPrice * item.Quantity;
+ var lineDiscountAmount = Math.Max(lineOriginalAmount - lineActualAmount, 0);
+ originalAmount += lineOriginalAmount;
+ discountAmount += lineDiscountAmount;
+ packagingFee += product.PackingFee * item.Quantity;
+ totalCount += item.Quantity;
+
+ lineItems.Add(new ResolvedOrderLine
+ {
+ ProductId = product.Id,
+ ProductName = product.Name,
+ Unit = product.Unit,
+ Quantity = item.Quantity,
+ UnitPrice = actualUnitPrice,
+ DiscountAmount = lineDiscountAmount,
+ SubTotal = lineActualAmount,
+ SkuName = MergeSkuName(skuName, addonNames),
+ AttributesJson = BuildAttributesJson(selectedSkuOptionIds, addonIds, addonNames)
+ });
+ }
+
+ // 5. 汇总订单级费用。
+ var deliveryFee = sceneSpec.DeliveryType == DeliveryType.Delivery && totalCount > 0 ? 4m : 0m;
+ return new ResolvedDraft
+ {
+ StoreId = storeIdValue,
+ StoreName = store.Name,
+ Scene = sceneSpec.Scene,
+ DeliveryType = sceneSpec.DeliveryType,
+ TotalCount = totalCount,
+ OriginalAmount = originalAmount,
+ PackagingFee = packagingFee,
+ DeliveryFee = deliveryFee,
+ DiscountAmount = discountAmount,
+ PayableAmount = originalAmount + packagingFee + deliveryFee - discountAmount,
+ Items = lineItems
+ };
+ }
+
+ private static MiniPriceEstimateResponse BuildEstimateResponse(ResolvedDraft draft)
+ {
+ return new MiniPriceEstimateResponse
+ {
+ StoreId = draft.StoreId.ToString(CultureInfo.InvariantCulture),
+ Scene = draft.Scene,
+ TotalCount = draft.TotalCount,
+ OriginalAmount = draft.OriginalAmount,
+ OriginalAmountText = FormatMoney(draft.OriginalAmount),
+ PackagingFee = draft.PackagingFee,
+ PackagingFeeText = FormatMoney(draft.PackagingFee),
+ DeliveryFee = draft.DeliveryFee,
+ DeliveryFeeText = FormatMoney(draft.DeliveryFee),
+ DiscountAmount = draft.DiscountAmount,
+ DiscountAmountText = FormatMoney(draft.DiscountAmount),
+ PayableAmount = draft.PayableAmount,
+ PayableAmountText = FormatMoney(draft.PayableAmount)
+ };
+ }
+
+ private static void ValidateAddonSelections(string productName, IReadOnlyList templates, IReadOnlyList selectedOptions)
+ {
+ foreach (var template in templates)
+ {
+ var selectedCount = selectedOptions.Count(option => option.TemplateId == template.TemplateId);
+ if (template.IsRequired && selectedCount < Math.Max(template.MinSelect, 1))
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】缺少必选项【{template.TemplateName}】");
+ }
+
+ if (template.SelectionType == 0 && selectedCount > 1)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】选项【{template.TemplateName}】只能选择 1 项");
+ }
+
+ if (template.MaxSelect > 0 && selectedCount > template.MaxSelect)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】选项【{template.TemplateName}】超出可选上限");
+ }
+ }
+
+ if (selectedOptions.Any(option => option.Stock <= 0))
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"商品【{productName}】加料库存不足");
+ }
+ }
+
+ private NpgsqlConnection OpenReadConnection()
+ {
+ return new NpgsqlConnection(ResolveReadConnectionString());
+ }
+
+ private string ResolveReadConnectionString()
+ {
+ return configuration["Database:DataSources:AppDatabase:Reads:0"]
+ ?? configuration["Database:DataSources:AppDatabase:Write"]
+ ?? throw new InvalidOperationException("缺少 AppDatabase 连接配置。");
+ }
+
+ private static void EnsureTenantResolved(long tenantId)
+ {
+ if (tenantId <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
+ }
+ }
+
+ private static long ParseId(string value, string fieldName)
+ {
+ if (!long.TryParse(value, out var parsed) || parsed <= 0)
+ {
+ throw new BusinessException(ErrorCodes.ValidationFailed, $"参数【{fieldName}】格式不正确");
+ }
+
+ return parsed;
+ }
+
+ private static SceneSpec ParseScene(string scene)
+ {
+ return scene.Trim() switch
+ {
+ "Delivery" => new SceneSpec("Delivery", DeliveryType.Delivery, "wm", '"' + "EnableDelivery" + '"'),
+ "Pickup" => new SceneSpec("Pickup", DeliveryType.Pickup, "pickup", '"' + "EnablePickup" + '"'),
+ "DineIn" => new SceneSpec("DineIn", DeliveryType.DineIn, "dine_in", '"' + "EnableDineIn" + '"'),
+ _ => throw new BusinessException(ErrorCodes.ValidationFailed, "不支持的场景类型")
+ };
+ }
+
+ private static IReadOnlyList BuildSupports(StoreRow row)
+ {
+ var supports = new List();
+ if (row.SupportsDelivery) supports.Add("Delivery");
+ if (row.SupportsPickup) supports.Add("Pickup");
+ if (row.SupportsDineIn) supports.Add("DineIn");
+ return supports;
+ }
+
+ private static IReadOnlyList SplitTexts(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return [];
+ }
+
+ var text = raw.Trim();
+ if (text.StartsWith("["))
+ {
+ try
+ {
+ return JsonSerializer.Deserialize>(text, JsonSerializerOptions)?.Where(item => !string.IsNullOrWhiteSpace(item)).ToList() ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ return text.Split([',', ',', '|', '、'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ private static List ParseSkuAttributeOptionIds(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return [];
+ }
+
+ try
+ {
+ var attributes = JsonSerializer.Deserialize>(raw, JsonSerializerOptions) ?? [];
+ return attributes.Where(item => item.OptionId > 0).Select(item => item.OptionId).ToList();
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string MergeSkuName(string skuName, IReadOnlyCollection addonNames)
+ {
+ var parts = new List();
+ if (!string.IsNullOrWhiteSpace(skuName)) parts.Add(skuName);
+ if (addonNames.Count > 0) parts.Add(string.Join('、', addonNames));
+ return string.Join(" / ", parts);
+ }
+
+ private static string BuildAttributesJson(IReadOnlyCollection skuOptionIds, IReadOnlyCollection addonIds, IReadOnlyCollection addonNames)
+ {
+ return JsonSerializer.Serialize(new
+ {
+ skuOptionIds,
+ addonIds,
+ addonNames
+ }, JsonSerializerOptions);
+ }
+
+ private static string MapGroupType(int templateType) => templateType switch
+ {
+ 0 => "spec",
+ 1 => "recipe",
+ 2 => "addon",
+ _ => "spec"
+ };
+
+ private static string MapSelectionType(int selectionType) => selectionType == 1 ? "multiple" : "single";
+
+ private static string MapOrderStatusText(int status) => status switch
+ {
+ 0 => "待支付",
+ 1 => "待制作",
+ 2 => "配送中",
+ 3 => "配送中",
+ 4 => "已完成",
+ 5 => "已取消",
+ _ => "处理中"
+ };
+
+ private static string MapPaymentStatusText(int paymentStatus) => paymentStatus switch
+ {
+ 0 => "未支付",
+ 1 => "支付中",
+ 2 => "已支付",
+ 3 => "支付失败",
+ 4 => "已退款",
+ _ => "未知"
+ };
+
+ private static string MapActionText(int status, int paymentStatus)
+ {
+ if (paymentStatus == 0 || status == 0) return "去支付";
+ if (status == 4 || status == 5) return "再来一单";
+ return "查看详情";
+ }
+
+ private static string MapSceneText(int deliveryType) => deliveryType switch
+ {
+ 2 => "Delivery",
+ 1 => "Pickup",
+ _ => "DineIn"
+ };
+
+ private static string BuildItemSummary(IReadOnlyList items)
+ {
+ if (items.Count == 0) return "暂无商品";
+ if (items.Count == 1) return $"{items[0].ProductName} ×{items[0].Quantity}";
+ return $"{items[0].ProductName} 等{items.Sum(item => item.Quantity)}件商品";
+ }
+
+ private static string FormatMoney(decimal value) => value.ToString("0.00", CultureInfo.InvariantCulture);
+
+ private static string FormatDateTime(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+
+ private sealed record SceneSpec(string Scene, DeliveryType DeliveryType, string CategoryChannelToken, string ProductEnabledColumn);
+
+ private sealed class ResolvedDraft
+ {
+ public long StoreId { get; init; }
+ public string StoreName { get; init; } = string.Empty;
+ public string Scene { get; init; } = string.Empty;
+ public DeliveryType DeliveryType { get; init; }
+ public int TotalCount { get; init; }
+ public decimal OriginalAmount { get; init; }
+ public decimal PackagingFee { get; init; }
+ public decimal DeliveryFee { get; init; }
+ public decimal DiscountAmount { get; init; }
+ public decimal PayableAmount { get; init; }
+ public IReadOnlyList Items { get; init; } = [];
+ }
+
+ private sealed class ResolvedOrderLine
+ {
+ public long ProductId { get; init; }
+ public string ProductName { get; init; } = string.Empty;
+ public string? SkuName { get; init; }
+ public string Unit { get; init; } = string.Empty;
+ public int Quantity { get; init; }
+ public decimal UnitPrice { get; init; }
+ public decimal DiscountAmount { get; init; }
+ public decimal SubTotal { get; init; }
+ public string AttributesJson { get; init; } = string.Empty;
+ }
+
+ private sealed class StoreRow
+ {
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string Address { get; init; } = string.Empty;
+ public string BusinessHours { get; init; } = string.Empty;
+ public string Tags { get; init; } = string.Empty;
+ public bool SupportsDelivery { get; init; }
+ public bool SupportsPickup { get; init; }
+ public bool SupportsDineIn { get; init; }
+ public int ProductCount { get; init; }
+ }
+
+ private sealed class StoreNameRow
+ {
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ }
+
+ private sealed class MenuProductRow
+ {
+ public long CategoryId { get; init; }
+ public string CategoryName { get; init; } = string.Empty;
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string Description { get; init; } = string.Empty;
+ public string CoverImageUrl { get; init; } = string.Empty;
+ public decimal DisplayPrice { get; init; }
+ public decimal? DisplayOriginalPrice { get; init; }
+ public int SalesMonthly { get; init; }
+ public string TagsJson { get; init; } = string.Empty;
+ public bool HasOptions { get; init; }
+ public bool SoldOut { get; init; }
+ }
+
+ private sealed class ProductCoreRow
+ {
+ public long Id { get; init; }
+ public long StoreId { get; init; }
+ public long CategoryId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string Subtitle { get; init; } = string.Empty;
+ public string Description { get; init; } = string.Empty;
+ public string CoverImageUrl { get; init; } = string.Empty;
+ public string GalleryImages { get; init; } = string.Empty;
+ public string Unit { get; init; } = string.Empty;
+ public decimal Price { get; init; }
+ public decimal? OriginalPrice { get; init; }
+ public int MonthlySales { get; init; }
+ public string TagsJson { get; init; } = string.Empty;
+ public decimal PackingFee { get; init; }
+ public int? StockQuantity { get; init; }
+ public bool HasEnabledSkus { get; init; }
+ }
+
+ private sealed class TemplateOptionRow
+ {
+ public long TemplateId { get; init; }
+ public string TemplateName { get; init; } = string.Empty;
+ public int TemplateType { get; init; }
+ public int SelectionType { get; init; }
+ public bool IsRequired { get; init; }
+ public int MinSelect { get; init; }
+ public int MaxSelect { get; init; }
+ public int TemplateSortOrder { get; init; }
+ public long OptionId { get; init; }
+ public string OptionName { get; init; } = string.Empty;
+ public decimal ExtraPrice { get; init; }
+ public int Stock { get; init; }
+ public int OptionSortOrder { get; init; }
+ }
+
+ private sealed class SkuRow
+ {
+ public long Id { get; init; }
+ public decimal Price { get; init; }
+ public decimal? OriginalPrice { get; init; }
+ public int? StockQuantity { get; init; }
+ public string AttributesJson { get; init; } = string.Empty;
+ public bool IsEnabled { get; init; }
+ }
+
+ private sealed class OptionNameRow
+ {
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ }
+
+ private sealed class AddonTemplateRow
+ {
+ public long TemplateId { get; init; }
+ public string TemplateName { get; init; } = string.Empty;
+ public bool IsRequired { get; init; }
+ public int MinSelect { get; init; }
+ public int MaxSelect { get; init; }
+ public int SelectionType { get; init; }
+ }
+
+ private sealed class AddonOptionRow
+ {
+ public long TemplateId { get; init; }
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public decimal ExtraPrice { get; init; }
+ public int Stock { get; init; }
+ }
+
+ private sealed class OrderRow
+ {
+ public long Id { get; init; }
+ public string OrderNo { get; init; } = string.Empty;
+ public long StoreId { get; init; }
+ public string StoreName { get; init; } = string.Empty;
+ public int Status { get; init; }
+ public int PaymentStatus { get; init; }
+ public int DeliveryType { get; init; }
+ public string CustomerName { get; init; } = string.Empty;
+ public string CustomerPhone { get; init; } = string.Empty;
+ public string TableNo { get; init; } = string.Empty;
+ public string Remark { get; init; } = string.Empty;
+ public decimal ItemsAmount { get; init; }
+ public decimal DiscountAmount { get; init; }
+ public decimal PayableAmount { get; init; }
+ public decimal PaidAmount { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? PaidAt { get; init; }
+ }
+
+ private sealed class OrderItemRow
+ {
+ public long Id { get; init; }
+ public long OrderId { get; init; }
+ public long ProductId { get; init; }
+ public string ProductName { get; init; } = string.Empty;
+ public string SkuName { get; init; } = string.Empty;
+ public string Unit { get; init; } = string.Empty;
+ public int Quantity { get; init; }
+ public decimal UnitPrice { get; init; }
+ public decimal SubTotal { get; init; }
+ }
+
+ private sealed class OrderHistoryRow
+ {
+ public int Status { get; init; }
+ public string Notes { get; init; } = string.Empty;
+ public DateTime OccurredAt { get; init; }
+ }
+
+ private sealed class SkuAttributeItem
+ {
+ public long TemplateId { get; init; }
+ public long OptionId { get; init; }
+ }
+}
+
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs
new file mode 100644
index 0000000..19b4f41
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/MiniOrderDbContext.cs
@@ -0,0 +1,64 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Orders.Entities;
+
+namespace TakeoutSaaS.Infrastructure.App.Persistence;
+
+///
+/// 小程序订单写库上下文。
+///
+public sealed class MiniOrderDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Orders => Set();
+ public DbSet OrderItems => Set();
+ public DbSet OrderStatusHistories => Set();
+
+ ///
+ /// 模型配置。
+ ///
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var orderBuilder = modelBuilder.Entity();
+ orderBuilder.ToTable("orders");
+ orderBuilder.HasKey(x => x.Id);
+ orderBuilder.Property(x => x.OrderNo).HasMaxLength(64).IsRequired();
+ orderBuilder.Property(x => x.Channel).HasConversion();
+ orderBuilder.Property(x => x.DeliveryType).HasConversion();
+ orderBuilder.Property(x => x.Status).HasConversion();
+ orderBuilder.Property(x => x.PaymentStatus).HasConversion();
+ orderBuilder.Property(x => x.ItemsAmount).HasPrecision(18, 2);
+ orderBuilder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
+ orderBuilder.Property(x => x.PayableAmount).HasPrecision(18, 2);
+ orderBuilder.Property(x => x.PaidAmount).HasPrecision(18, 2);
+ orderBuilder.Property(x => x.CustomerName).HasMaxLength(64);
+ orderBuilder.Property(x => x.CustomerPhone).HasMaxLength(32);
+ orderBuilder.Property(x => x.TableNo).HasMaxLength(64);
+ orderBuilder.Property(x => x.QueueNumber).HasMaxLength(64);
+ orderBuilder.Property(x => x.CancelReason).HasMaxLength(256);
+ orderBuilder.Property(x => x.Remark).HasMaxLength(512);
+
+ var itemBuilder = modelBuilder.Entity();
+ itemBuilder.ToTable("order_items");
+ itemBuilder.HasKey(x => x.Id);
+ itemBuilder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
+ itemBuilder.Property(x => x.SkuName).HasMaxLength(256);
+ itemBuilder.Property(x => x.Unit).HasMaxLength(32);
+ itemBuilder.Property(x => x.UnitPrice).HasPrecision(18, 2);
+ itemBuilder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
+ itemBuilder.Property(x => x.SubTotal).HasPrecision(18, 2);
+ itemBuilder.Property(x => x.AttributesJson).HasColumnType("text");
+ itemBuilder.HasOne()
+ .WithMany()
+ .HasForeignKey(x => x.OrderId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ var historyBuilder = modelBuilder.Entity();
+ historyBuilder.ToTable("order_status_histories");
+ historyBuilder.HasKey(x => x.Id);
+ historyBuilder.Property(x => x.Status).HasConversion();
+ historyBuilder.Property(x => x.Notes).HasMaxLength(256);
+ historyBuilder.HasOne()
+ .WithMany()
+ .HasForeignKey(x => x.OrderId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs
new file mode 100644
index 0000000..63ba94b
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Orders.Entities;
+using TakeoutSaaS.Domain.Orders.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 订单写仓储 EF 实现。
+///
+public sealed class EfOrderRepository(MiniOrderDbContext dbContext) : IOrderRepository
+{
+ ///
+ public Task AddAsync(Order order, CancellationToken cancellationToken = default)
+ {
+ _ = cancellationToken;
+ dbContext.Orders.Add(order);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task AddItemsAsync(IReadOnlyCollection items, CancellationToken cancellationToken = default)
+ {
+ _ = cancellationToken;
+ if (items.Count > 0)
+ {
+ dbContext.OrderItems.AddRange(items);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default)
+ {
+ _ = cancellationToken;
+ dbContext.OrderStatusHistories.Add(history);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default)
+ {
+ return dbContext.Orders.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == orderId && x.DeletedAt == null, cancellationToken);
+ }
+
+ ///
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs
new file mode 100644
index 0000000..573c5d2
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Tenancy/DatabaseTenantCodeResolver.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Configuration;
+using Dapper;
+using Npgsql;
+using TakeoutSaaS.Module.Tenancy;
+
+namespace TakeoutSaaS.Infrastructure.App.Tenancy;
+
+///
+/// 基于业务库的租户编码解析器。
+///
+public sealed class DatabaseTenantCodeResolver(IConfiguration configuration) : ITenantCodeResolver
+{
+ ///
+ public async Task ResolveAsync(string code, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ return null;
+ }
+
+ var connectionString = configuration["Database:DataSources:AppDatabase:Reads:0"]
+ ?? configuration["Database:DataSources:AppDatabase:Write"];
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ return null;
+ }
+
+ await using var connection = new NpgsqlConnection(connectionString);
+ const string sql = """
+select "Id"
+from public.tenants
+where "DeletedAt" is null
+ and "Code" = @Code
+limit 1;
+""";
+
+ return await connection.ExecuteScalarAsync(new CommandDefinition(
+ sql,
+ new { Code = code.Trim() },
+ cancellationToken: cancellationToken));
+ }
+}
+
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
index 3167dba..052be42 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj
@@ -5,12 +5,18 @@
enable
-
-
+
+
+
+
+
+
+
+