feat: add mini ordering catalog and order APIs
All checks were successful
Build and Deploy MiniApi / build-and-deploy (push) Successful in 23s

This commit is contained in:
2026-03-10 10:03:32 +08:00
parent 2a0e2e6d62
commit 227266d183
33 changed files with 2483 additions and 19 deletions

View File

@@ -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;
/// <summary>
/// 小程序商品与门店查询接口。
/// </summary>
[ApiVersion("1.0")]
[Route("api/mini/v{version:apiVersion}")]
public sealed class CatalogController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController
{
/// <summary>
/// 获取当前租户下可用门店列表。
/// </summary>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>门店摘要列表。</returns>
[HttpGet("stores")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MiniStoreSummaryDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MiniStoreSummaryDto>>> GetStoresAsync(CancellationToken cancellationToken)
{
var data = await miniAppService.GetStoresAsync(tenantProvider.GetCurrentTenantId(), cancellationToken);
return ApiResponse<IReadOnlyList<MiniStoreSummaryDto>>.Ok(data);
}
/// <summary>
/// 获取指定门店在当前履约场景下的商品分类。
/// </summary>
/// <param name="storeId">门店编号。</param>
/// <param name="scene">履约场景。</param>
/// <param name="channel">访问渠道。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>分类列表。</returns>
[HttpGet("categories")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MiniCategoryDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MiniCategoryDto>>> 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<IReadOnlyList<MiniCategoryDto>>.Ok(data);
}
/// <summary>
/// 获取指定门店的菜单分组与商品列表。
/// </summary>
/// <param name="storeId">门店编号。</param>
/// <param name="scene">履约场景。</param>
/// <param name="channel">访问渠道。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>菜单分组列表。</returns>
[HttpGet("menus/{storeId}")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MiniMenuSectionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MiniMenuSectionDto>>> 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<IReadOnlyList<MiniMenuSectionDto>>.Ok(data);
}
/// <summary>
/// 获取商品详情与可选规格信息。
/// </summary>
/// <param name="productId">商品编号。</param>
/// <param name="scene">履约场景。</param>
/// <param name="channel">访问渠道。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>商品详情。</returns>
[HttpGet("products/{productId}")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniProductDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniProductDetailDto>> 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<MiniProductDetailDto>.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse<MiniProductDetailDto>.Ok(data);
}
/// <summary>
/// 试算购物车金额。
/// </summary>
/// <param name="request">试算请求。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>金额试算结果。</returns>
[HttpPost("products/price-estimate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniPriceEstimateResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniPriceEstimateResponse>> EstimatePriceAsync([FromBody] MiniPriceEstimateRequest request, CancellationToken cancellationToken)
{
var data = await miniAppService.EstimatePriceAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken);
return ApiResponse<MiniPriceEstimateResponse>.Ok(data);
}
/// <summary>
/// 下单前校验商品与价格是否有效。
/// </summary>
/// <param name="request">结算校验请求。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>结算校验结果。</returns>
[HttpPost("products/checkout-validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniCheckoutValidationResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniCheckoutValidationResponse>> CheckoutValidateAsync([FromBody] MiniCheckoutValidationRequest request, CancellationToken cancellationToken)
{
var data = await miniAppService.ValidateCheckoutAsync(tenantProvider.GetCurrentTenantId(), request, cancellationToken);
return ApiResponse<MiniCheckoutValidationResponse>.Ok(data);
}
}

View File

@@ -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;
/// <summary>
/// 小程序订单接口。
/// </summary>
[ApiVersion("1.0")]
[Route("api/mini/v{version:apiVersion}/orders")]
public sealed class OrdersController(IMiniAppService miniAppService, ITenantProvider tenantProvider) : BaseApiController
{
/// <summary>
/// 获取当前顾客的订单列表。
/// </summary>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>订单摘要列表。</returns>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MiniOrderSummaryDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MiniOrderSummaryDto>>> GetOrdersAsync(CancellationToken cancellationToken)
{
var data = await miniAppService.GetOrdersAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerPhone(), cancellationToken);
return ApiResponse<IReadOnlyList<MiniOrderSummaryDto>>.Ok(data);
}
/// <summary>
/// 获取指定订单详情。
/// </summary>
/// <param name="orderId">订单编号。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>订单详情。</returns>
[HttpGet("{orderId}")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniOrderDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniOrderDetailDto>> GetOrderDetailAsync([FromRoute] string orderId, CancellationToken cancellationToken)
{
var data = await miniAppService.GetOrderDetailAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken);
return data == null ? ApiResponse<MiniOrderDetailDto>.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse<MiniOrderDetailDto>.Ok(data);
}
/// <summary>
/// 创建订单。
/// </summary>
/// <param name="request">下单请求。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>创建结果。</returns>
[HttpPost]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniCreateOrderResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniCreateOrderResponse>> CreateOrderAsync([FromBody] MiniCreateOrderRequest request, CancellationToken cancellationToken)
{
var data = await miniAppService.CreateOrderAsync(tenantProvider.GetCurrentTenantId(), ResolveCustomerName(), ResolveCustomerPhone(), request, cancellationToken);
return ApiResponse<MiniCreateOrderResponse>.Ok(data);
}
/// <summary>
/// 模拟支付指定订单。
/// </summary>
/// <param name="orderId">订单编号。</param>
/// <param name="cancellationToken">请求取消令牌。</param>
/// <returns>模拟支付结果。</returns>
[HttpPost("{orderId}/mock-pay")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniMockPayResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniMockPayResponse>> MockPayAsync([FromRoute] string orderId, CancellationToken cancellationToken)
{
var data = await miniAppService.MockPayAsync(tenantProvider.GetCurrentTenantId(), orderId, ResolveCustomerPhone(), cancellationToken);
return ApiResponse<MiniMockPayResponse>.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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 分类摘要。
/// </summary>
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; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 结算校验请求。
/// </summary>
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<MiniOrderLineInput> Items { get; init; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 结算校验结果。
/// </summary>
public sealed class MiniCheckoutValidationResponse
{
public bool Valid { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 创建订单请求。
/// </summary>
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<MiniOrderLineInput> Items { get; init; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 创建订单结果。
/// </summary>
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; }
}

View File

@@ -0,0 +1,30 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 菜单分组。
/// </summary>
public sealed class MiniMenuSectionDto
{
public string CategoryId { get; init; } = string.Empty;
public string CategoryName { get; init; } = string.Empty;
public IReadOnlyList<MiniMenuProductDto> Products { get; init; } = [];
/// <summary>
/// 菜单商品卡片。
/// </summary>
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<string> TagTexts { get; init; } = [];
public bool SoldOut { get; init; }
public bool HasOptions { get; init; }
}
}

View File

@@ -0,0 +1,14 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 模拟支付结果。
/// </summary>
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;
}

View File

@@ -0,0 +1,59 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 订单详情。
/// </summary>
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<MiniOrderItemDto> Items { get; init; } = [];
public IReadOnlyList<MiniOrderTimelineDto> Timeline { get; init; } = [];
/// <summary>
/// 订单商品明细。
/// </summary>
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";
}
/// <summary>
/// 订单时间线。
/// </summary>
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;
}
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 结算行项目输入。
/// </summary>
public sealed class MiniOrderLineInput
{
public string ProductId { get; init; } = string.Empty;
public string? SkuId { get; init; }
public int Quantity { get; init; }
public IReadOnlyList<string> AddonItemIds { get; init; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 订单摘要。
/// </summary>
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;
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 金额试算请求。
/// </summary>
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<MiniOrderLineInput> Items { get; init; } = [];
}

View File

@@ -0,0 +1,21 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 金额试算结果。
/// </summary>
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";
}

View File

@@ -0,0 +1,69 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 商品详情。
/// </summary>
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<string> 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<string> TagTexts { get; init; } = [];
public string? DefaultSkuId { get; init; }
public IReadOnlyList<MiniProductSkuDto> Skus { get; init; } = [];
public IReadOnlyList<MiniProductOptionGroupDto> OptionGroups { get; init; } = [];
/// <summary>
/// 商品 SKU。
/// </summary>
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<string> SelectedOptionIds { get; init; } = [];
}
/// <summary>
/// 商品选项分组。
/// </summary>
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<MiniProductOptionDto> Options { get; init; } = [];
}
/// <summary>
/// 商品选项。
/// </summary>
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; }
}
}

View File

@@ -0,0 +1,14 @@
namespace TakeoutSaaS.Application.App.Mini.Contracts;
/// <summary>
/// 门店摘要。
/// </summary>
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<string> Supports { get; init; } = [];
public IReadOnlyList<string> TagTexts { get; init; } = [];
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Application.App.Mini.Contracts;
namespace TakeoutSaaS.Application.App.Mini;
/// <summary>
/// 小程序真实业务服务抽象。
/// </summary>
public interface IMiniAppService
{
/// <summary>
/// 查询可选门店。
/// </summary>
Task<IReadOnlyList<MiniStoreSummaryDto>> GetStoresAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 查询门店分类。
/// </summary>
Task<IReadOnlyList<MiniCategoryDto>> GetCategoriesAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default);
/// <summary>
/// 查询门店菜单。
/// </summary>
Task<IReadOnlyList<MiniMenuSectionDto>> GetMenuAsync(long tenantId, string storeId, string scene, string channel, CancellationToken cancellationToken = default);
/// <summary>
/// 查询商品详情。
/// </summary>
Task<MiniProductDetailDto?> GetProductDetailAsync(long tenantId, string productId, string scene, string channel, CancellationToken cancellationToken = default);
/// <summary>
/// 试算订单金额。
/// </summary>
Task<MiniPriceEstimateResponse> EstimatePriceAsync(long tenantId, MiniPriceEstimateRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 校验结算请求。
/// </summary>
Task<MiniCheckoutValidationResponse> ValidateCheckoutAsync(long tenantId, MiniCheckoutValidationRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 查询顾客订单列表。
/// </summary>
Task<IReadOnlyList<MiniOrderSummaryDto>> GetOrdersAsync(long tenantId, string customerPhone, CancellationToken cancellationToken = default);
/// <summary>
/// 查询顾客订单详情。
/// </summary>
Task<MiniOrderDetailDto?> GetOrderDetailAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default);
/// <summary>
/// 创建订单。
/// </summary>
Task<MiniCreateOrderResponse> CreateOrderAsync(long tenantId, string customerName, string customerPhone, MiniCreateOrderRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 模拟支付。
/// </summary>
Task<MiniMockPayResponse> MockPayAsync(long tenantId, string orderId, string customerPhone, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,92 @@
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Orders.Entities;
/// <summary>
/// 小程序订单聚合根。
/// </summary>
public sealed class Order : MultiTenantEntityBase
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 下单渠道。
/// </summary>
public OrderChannel Channel { get; set; } = OrderChannel.MiniProgram;
/// <summary>
/// 履约方式。
/// </summary>
public DeliveryType DeliveryType { get; set; } = DeliveryType.Delivery;
/// <summary>
/// 订单状态。
/// </summary>
public OrderStatus Status { get; set; } = OrderStatus.PendingPayment;
/// <summary>
/// 支付状态。
/// </summary>
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Unpaid;
/// <summary>
/// 顾客姓名。
/// </summary>
public string? CustomerName { get; set; }
/// <summary>
/// 顾客手机号。
/// </summary>
public string? CustomerPhone { get; set; }
/// <summary>
/// 桌号。
/// </summary>
public string? TableNo { get; set; }
/// <summary>
/// 排队号。
/// </summary>
public string? QueueNumber { get; set; }
/// <summary>
/// 预约 ID。
/// </summary>
public long? ReservationId { get; set; }
/// <summary>
/// 商品原价合计。
/// </summary>
public decimal ItemsAmount { get; set; }
/// <summary>
/// 优惠金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal PayableAmount { get; set; }
/// <summary>
/// 实付金额。
/// </summary>
public decimal PaidAmount { get; set; }
/// <summary>
/// 支付时间。
/// </summary>
public DateTime? PaidAt { get; set; }
/// <summary>
/// 完成时间。
/// </summary>
public DateTime? FinishedAt { get; set; }
/// <summary>
/// 取消时间。
/// </summary>
public DateTime? CancelledAt { get; set; }
/// <summary>
/// 取消原因。
/// </summary>
public string? CancelReason { get; set; }
/// <summary>
/// 订单备注。
/// </summary>
public string? Remark { get; set; }
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Orders.Entities;
/// <summary>
/// 订单明细。
/// </summary>
public sealed class OrderItem : MultiTenantEntityBase
{
/// <summary>
/// 订单 ID。
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// 商品 ID。
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// 商品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// SKU 名称。
/// </summary>
public string? SkuName { get; set; }
/// <summary>
/// 单位。
/// </summary>
public string? Unit { get; set; }
/// <summary>
/// 数量。
/// </summary>
public int Quantity { get; set; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; set; }
/// <summary>
/// 优惠金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 小计。
/// </summary>
public decimal SubTotal { get; set; }
/// <summary>
/// 属性 JSON。
/// </summary>
public string? AttributesJson { get; set; }
}

View File

@@ -0,0 +1,31 @@
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Orders.Entities;
/// <summary>
/// 订单状态流转记录。
/// </summary>
public sealed class OrderStatusHistory : MultiTenantEntityBase
{
/// <summary>
/// 订单 ID。
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// 变更后的状态。
/// </summary>
public OrderStatus Status { get; set; }
/// <summary>
/// 操作人 ID。
/// </summary>
public long? OperatorId { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 发生时间。
/// </summary>
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,20 @@
namespace TakeoutSaaS.Domain.Orders.Enums;
/// <summary>
/// 履约方式。
/// </summary>
public enum DeliveryType
{
/// <summary>
/// 堂食。
/// </summary>
DineIn = 0,
/// <summary>
/// 自提。
/// </summary>
Pickup = 1,
/// <summary>
/// 配送。
/// </summary>
Delivery = 2
}

View File

@@ -0,0 +1,20 @@
namespace TakeoutSaaS.Domain.Orders.Enums;
/// <summary>
/// 下单渠道。
/// </summary>
public enum OrderChannel
{
/// <summary>
/// 未知渠道。
/// </summary>
Unknown = 0,
/// <summary>
/// 小程序。
/// </summary>
MiniProgram = 1,
/// <summary>
/// 扫码点餐。
/// </summary>
ScanToOrder = 2
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Domain.Orders.Enums;
/// <summary>
/// 订单状态。
/// </summary>
public enum OrderStatus
{
/// <summary>
/// 待付款。
/// </summary>
PendingPayment = 0,
/// <summary>
/// 已付款待制作。
/// </summary>
AwaitingPreparation = 1,
/// <summary>
/// 制作中。
/// </summary>
InProgress = 2,
/// <summary>
/// 待取餐或待送达。
/// </summary>
Ready = 3,
/// <summary>
/// 已完成。
/// </summary>
Completed = 4,
/// <summary>
/// 已取消。
/// </summary>
Cancelled = 5
}

View File

@@ -0,0 +1,30 @@
using TakeoutSaaS.Domain.Orders.Entities;
namespace TakeoutSaaS.Domain.Orders.Repositories;
/// <summary>
/// 订单写仓储。
/// </summary>
public interface IOrderRepository
{
/// <summary>
/// 新增订单。
/// </summary>
Task AddAsync(Order order, CancellationToken cancellationToken = default);
/// <summary>
/// 批量新增订单明细。
/// </summary>
Task AddItemsAsync(IReadOnlyCollection<OrderItem> items, CancellationToken cancellationToken = default);
/// <summary>
/// 新增状态流转记录。
/// </summary>
Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default);
/// <summary>
/// 查询可写订单。
/// </summary>
Task<Order?> FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default);
/// <summary>
/// 保存变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,28 @@
namespace TakeoutSaaS.Domain.Payments.Enums;
/// <summary>
/// 支付状态。
/// </summary>
public enum PaymentStatus
{
/// <summary>
/// 未支付。
/// </summary>
Unpaid = 0,
/// <summary>
/// 支付中。
/// </summary>
Paying = 1,
/// <summary>
/// 已支付。
/// </summary>
Paid = 2,
/// <summary>
/// 支付失败。
/// </summary>
Failed = 3,
/// <summary>
/// 已退款。
/// </summary>
Refunded = 4
}

View File

@@ -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
{
/// <summary>
/// 注册骨架所需的基础设施能力。
/// 注册小程序端基础设施能力。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
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<MiniOrderDbContext>(options => options.UseNpgsql(writeConnectionString));
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IMiniAppService, MiniAppService>();
services.AddScoped<ITenantCodeResolver, DatabaseTenantCodeResolver>();
return services;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Orders.Entities;
namespace TakeoutSaaS.Infrastructure.App.Persistence;
/// <summary>
/// 小程序订单写库上下文。
/// </summary>
public sealed class MiniOrderDbContext(DbContextOptions<MiniOrderDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<OrderStatusHistory> OrderStatusHistories => Set<OrderStatusHistory>();
/// <summary>
/// 模型配置。
/// </summary>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var orderBuilder = modelBuilder.Entity<Order>();
orderBuilder.ToTable("orders");
orderBuilder.HasKey(x => x.Id);
orderBuilder.Property(x => x.OrderNo).HasMaxLength(64).IsRequired();
orderBuilder.Property(x => x.Channel).HasConversion<int>();
orderBuilder.Property(x => x.DeliveryType).HasConversion<int>();
orderBuilder.Property(x => x.Status).HasConversion<int>();
orderBuilder.Property(x => x.PaymentStatus).HasConversion<int>();
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<OrderItem>();
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<Order>()
.WithMany()
.HasForeignKey(x => x.OrderId)
.OnDelete(DeleteBehavior.Cascade);
var historyBuilder = modelBuilder.Entity<OrderStatusHistory>();
historyBuilder.ToTable("order_status_histories");
historyBuilder.HasKey(x => x.Id);
historyBuilder.Property(x => x.Status).HasConversion<int>();
historyBuilder.Property(x => x.Notes).HasMaxLength(256);
historyBuilder.HasOne<Order>()
.WithMany()
.HasForeignKey(x => x.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -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;
/// <summary>
/// 订单写仓储 EF 实现。
/// </summary>
public sealed class EfOrderRepository(MiniOrderDbContext dbContext) : IOrderRepository
{
/// <inheritdoc />
public Task AddAsync(Order order, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
dbContext.Orders.Add(order);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddItemsAsync(IReadOnlyCollection<OrderItem> items, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
if (items.Count > 0)
{
dbContext.OrderItems.AddRange(items);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
dbContext.OrderStatusHistories.Add(history);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<Order?> FindAsync(long tenantId, long orderId, CancellationToken cancellationToken = default)
{
return dbContext.Orders.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == orderId && x.DeletedAt == null, cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Configuration;
using Dapper;
using Npgsql;
using TakeoutSaaS.Module.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Tenancy;
/// <summary>
/// 基于业务库的租户编码解析器。
/// </summary>
public sealed class DatabaseTenantCodeResolver(IConfiguration configuration) : ITenantCodeResolver
{
/// <inheritdoc />
public async Task<long?> 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<long?>(new CommandDefinition(
sql,
new { Code = code.Trim() },
cancellationToken: cancellationToken));
}
}

View File

@@ -5,12 +5,18 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
</Project>