diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreController.cs new file mode 100644 index 0000000..976fb52 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreController.cs @@ -0,0 +1,112 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 租户端门店管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/store")] +public sealed class StoreController(IMediator mediator) : BaseApiController +{ + /// + /// 查询门店列表。 + /// + /// 查询参数。 + /// 取消标记。 + /// 分页列表。 + [HttpGet("list")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> List([FromQuery] SearchStoresQuery query, CancellationToken cancellationToken) + { + // 1. 查询门店分页 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 + return ApiResponse.Ok(result); + } + + /// + /// 查询门店统计。 + /// + /// 取消标记。 + /// 统计结果。 + [HttpGet("stats")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> Stats(CancellationToken cancellationToken) + { + // 1. 查询门店统计 + var result = await mediator.Send(new GetStoreStatsQuery(), cancellationToken); + + // 2. 返回统计结果 + return ApiResponse.Ok(result); + } + + /// + /// 创建门店。 + /// + /// 创建命令。 + /// 取消标记。 + /// 创建结果。 + [HttpPost("create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken) + { + // 1. 执行创建 + await mediator.Send(command, cancellationToken); + + // 2. 返回成功响应 + return ApiResponse.Ok(null); + } + + /// + /// 更新门店。 + /// + /// 更新命令。 + /// 取消标记。 + /// 更新结果。 + [HttpPost("update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task> Update([FromBody] UpdateStoreCommand command, CancellationToken cancellationToken) + { + // 1. 执行更新 + await mediator.Send(command, cancellationToken); + + // 2. 返回成功响应 + return ApiResponse.Ok(null); + } + + /// + /// 删除门店。 + /// + /// 删除命令。 + /// 取消标记。 + /// 删除结果。 + [HttpPost("delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task> Delete([FromBody] DeleteStoreCommand command, CancellationToken cancellationToken) + { + // 1. 执行删除 + await mediator.Send(command, cancellationToken); + + // 2. 返回成功响应 + return ApiResponse.Ok(null); + } +} + diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs index 61bebe3..c970f08 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Program.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs @@ -9,7 +9,11 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; +using TakeoutSaaS.Application.App.Extensions; +using TakeoutSaaS.Infrastructure.App.Extensions; +using TakeoutSaaS.Infrastructure.Identity.Extensions; using TakeoutSaaS.Module.Authorization.Extensions; +using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Security; using TakeoutSaaS.Shared.Web.Swagger; @@ -77,6 +81,10 @@ if (isDevelopment) } // 5. 注册鉴权授权与权限策略 +builder.Services.AddAppApplication(); +builder.Services.AddAppInfrastructure(builder.Configuration); +builder.Services.AddJwtAuthentication(builder.Configuration); +builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddAuthorization(); builder.Services.AddPermissionAuthorization(); builder.Services.AddHealthChecks(); @@ -150,10 +158,14 @@ app.UseCors("TenantApiCors"); // 1. (空行后) 通用 Web Core 中间件(异常、ProblemDetails、日志等) app.UseSharedWebCore(); -// 2. (空行后) 执行授权 +// 2. (空行后) 执行认证并解析租户 +app.UseAuthentication(); +app.UseTenantResolution(); + +// 3. (空行后) 执行授权 app.UseAuthorization(); -// 3. (空行后) 开发环境启用 Swagger +// 4. (空行后) 开发环境启用 Swagger if (app.Environment.IsDevelopment()) { app.UseSharedSwagger(); diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..8bd29bc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using TakeoutSaaS.Application.App.Common.Behaviors; +using TakeoutSaaS.Application.App.Stores.Services; + +namespace TakeoutSaaS.Application.App.Extensions; + +/// +/// 业务应用层服务注册。 +/// +public static class AppApplicationServiceCollectionExtensions +{ + /// + /// 注册业务应用层(MediatR、验证器、管道行为)。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddAppApplication(this IServiceCollection services) + { + // 1. 注册 MediatR 处理器 + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + + // 2. 注册 FluentValidation 验证器 + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + // 3. 注册统一验证管道 + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + // 4. 注册门店模块上下文服务 + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs new file mode 100644 index 0000000..76ba506 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 租户端创建门店命令。 +/// +public sealed record CreateStoreCommand : IRequest +{ + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 联系电话。 + /// + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 负责人。 + /// + public string ManagerName { get; init; } = string.Empty; + + /// + /// 门店地址。 + /// + public string Address { get; init; } = string.Empty; + + /// + /// 门店封面图。 + /// + public string? CoverImage { get; init; } + + /// + /// 营业状态。 + /// + public StoreBusinessStatus? BusinessStatus { get; init; } + + /// + /// 服务方式。 + /// + public IReadOnlyList? ServiceTypes { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs new file mode 100644 index 0000000..6c525ae --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs @@ -0,0 +1,18 @@ +using MediatR; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 租户端删除门店命令。 +/// +public sealed record DeleteStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs new file mode 100644 index 0000000..8c0994f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs @@ -0,0 +1,60 @@ +using MediatR; +using System.Text.Json.Serialization; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 租户端更新门店命令。 +/// +public sealed record UpdateStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 联系电话。 + /// + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 负责人。 + /// + public string ManagerName { get; init; } = string.Empty; + + /// + /// 门店地址。 + /// + public string Address { get; init; } = string.Empty; + + /// + /// 门店封面图。 + /// + public string? CoverImage { get; init; } + + /// + /// 营业状态。 + /// + public StoreBusinessStatus? BusinessStatus { get; init; } + + /// + /// 服务方式。 + /// + public IReadOnlyList? ServiceTypes { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListItemDto.cs new file mode 100644 index 0000000..aaea678 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListItemDto.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 租户端门店列表项。 +/// +public sealed record StoreListItemDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 联系电话。 + /// + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 负责人。 + /// + public string ManagerName { get; init; } = string.Empty; + + /// + /// 门店地址。 + /// + public string Address { get; init; } = string.Empty; + + /// + /// 门店封面图。 + /// + public string? CoverImage { get; init; } + + /// + /// 营业状态。 + /// + public StoreBusinessStatus BusinessStatus { get; init; } + + /// + /// 审核状态。 + /// + public StoreAuditStatus AuditStatus { get; init; } + + /// + /// 服务方式。 + /// + public IReadOnlyList ServiceTypes { get; init; } = []; + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListResultDto.cs new file mode 100644 index 0000000..90b0159 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreListResultDto.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 租户端门店列表分页结果。 +/// +public sealed record StoreListResultDto +{ + /// + /// 当前页数据。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总条数。 + /// + public int Total { get; init; } + + /// + /// 当前页码(从 1 开始)。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStatsDto.cs new file mode 100644 index 0000000..c480b7c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStatsDto.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 租户端门店统计信息。 +/// +public sealed record StoreStatsDto +{ + /// + /// 门店总数。 + /// + public int Total { get; init; } + + /// + /// 营业中数量。 + /// + public int Operating { get; init; } + + /// + /// 休息中数量。 + /// + public int Resting { get; init; } + + /// + /// 待审核数量。 + /// + public int PendingAudit { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Enums/ServiceType.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Enums/ServiceType.cs new file mode 100644 index 0000000..d1876f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Enums/ServiceType.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Application.App.Stores.Enums; + +/// +/// 门店服务方式。 +/// +public enum ServiceType +{ + /// + /// 外卖配送。 + /// + Delivery = 1, + + /// + /// 到店自提。 + /// + Pickup = 2, + + /// + /// 堂食。 + /// + DineIn = 3 +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs new file mode 100644 index 0000000..beabb30 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 租户端创建门店命令处理器。 +/// +public sealed class CreateStoreCommandHandler( + StoreContextService storeContextService, + IStoreRepository storeRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreCommand request, CancellationToken cancellationToken) + { + // 1. 解析上下文 + var context = storeContextService.GetRequiredContext(); + + // 2. 校验编码唯一性 + var existingStores = await storeRepository.SearchAsync( + context.TenantId, + context.MerchantId, + status: null, + auditStatus: null, + businessStatus: null, + ownershipType: null, + keyword: null, + includeDeleted: false, + cancellationToken: cancellationToken); + var normalizedCode = request.Code.Trim(); + var hasDuplicateCode = existingStores.Any(store => string.Equals(store.Code, normalizedCode, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicateCode) + { + throw new BusinessException(ErrorCodes.Conflict, "门店编码已存在"); + } + + // 3. 组装门店实体 + var serviceTypes = request.ServiceTypes?.Count > 0 + ? request.ServiceTypes + : [ServiceType.Delivery]; + var store = new Store + { + TenantId = context.TenantId, + MerchantId = context.MerchantId, + Code = normalizedCode, + Name = request.Name.Trim(), + Phone = request.ContactPhone.Trim(), + ManagerName = request.ManagerName.Trim(), + Address = request.Address.Trim(), + CoverImageUrl = request.CoverImage?.Trim(), + SignboardImageUrl = request.CoverImage?.Trim(), + OwnershipType = StoreOwnershipType.SameEntity, + AuditStatus = StoreAuditStatus.Draft, + BusinessStatus = request.BusinessStatus ?? StoreBusinessStatus.Open, + Status = StoreStatus.Operating + }; + StoreListMapping.ApplyServiceTypes(store, serviceTypes); + + // 4. 持久化 + await storeRepository.AddStoreAsync(store, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs new file mode 100644 index 0000000..dec1246 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 租户端删除门店命令处理器。 +/// +public sealed class DeleteStoreCommandHandler( + StoreContextService storeContextService, + IStoreRepository storeRepository) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreCommand request, CancellationToken cancellationToken) + { + // 1. 解析上下文 + var context = storeContextService.GetRequiredContext(); + + // 2. 查询并校验门店归属 + var existing = await storeRepository.FindByIdAsync(request.Id, context.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + if (existing.MerchantId != context.MerchantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店"); + } + + // 3. 执行删除 + await storeRepository.DeleteStoreAsync(existing.Id, context.TenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreStatsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreStatsQueryHandler.cs new file mode 100644 index 0000000..f5d4137 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreStatsQueryHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 租户端门店统计查询处理器。 +/// +public sealed class GetStoreStatsQueryHandler( + StoreContextService storeContextService, + IStoreRepository storeRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreStatsQuery request, CancellationToken cancellationToken) + { + // 1. 解析上下文 + var context = storeContextService.GetRequiredContext(); + + // 2. 查询当前商户全部门店 + var stores = await storeRepository.SearchAsync( + context.TenantId, + context.MerchantId, + status: null, + auditStatus: null, + businessStatus: null, + ownershipType: null, + keyword: null, + includeDeleted: false, + cancellationToken: cancellationToken); + + // 3. 统计并返回 + return new StoreStatsDto + { + Total = stores.Count, + Operating = stores.Count(store => store.BusinessStatus == StoreBusinessStatus.Open), + Resting = stores.Count(store => store.BusinessStatus == StoreBusinessStatus.Resting), + PendingAudit = stores.Count(store => store.AuditStatus == StoreAuditStatus.Pending) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs new file mode 100644 index 0000000..bdc64d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Repositories; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 租户端门店列表查询处理器。 +/// +public sealed class SearchStoresQueryHandler( + StoreContextService storeContextService, + IStoreRepository storeRepository) + : IRequestHandler +{ + /// + public async Task Handle(SearchStoresQuery request, CancellationToken cancellationToken) + { + // 1. 解析上下文 + var context = storeContextService.GetRequiredContext(); + + // 2. 查询当前商户门店集合 + var stores = await storeRepository.SearchAsync( + context.TenantId, + context.MerchantId, + status: null, + auditStatus: request.AuditStatus, + businessStatus: request.BusinessStatus, + ownershipType: null, + keyword: request.Keyword, + includeDeleted: false, + cancellationToken: cancellationToken); + + // 3. 按服务方式二次过滤 + var filtered = request.ServiceType.HasValue + ? stores.Where(store => StoreListMapping.MatchServiceType(store, request.ServiceType.Value)).ToList() + : stores.ToList(); + + // 4. 按创建时间倒序并分页 + var page = request.Page <= 0 ? 1 : request.Page; + var pageSize = request.PageSize <= 0 ? 10 : request.PageSize; + var ordered = filtered + .OrderByDescending(store => store.CreatedAt) + .ToList(); + var items = ordered + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(StoreListMapping.ToListItemDto) + .ToList(); + + // 5. 返回分页结构 + return new StoreListResultDto + { + Items = items, + Total = filtered.Count, + Page = page, + PageSize = pageSize + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs new file mode 100644 index 0000000..c5e8efc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 租户端更新门店命令处理器。 +/// +public sealed class UpdateStoreCommandHandler( + StoreContextService storeContextService, + IStoreRepository storeRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreCommand request, CancellationToken cancellationToken) + { + // 1. 解析上下文 + var context = storeContextService.GetRequiredContext(); + + // 2. 查询并校验门店归属 + var existing = await storeRepository.FindByIdAsync(request.Id, context.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + if (existing.MerchantId != context.MerchantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店"); + } + + // 3. 校验编码唯一性 + var existingStores = await storeRepository.SearchAsync( + context.TenantId, + context.MerchantId, + status: null, + auditStatus: null, + businessStatus: null, + ownershipType: null, + keyword: null, + includeDeleted: false, + cancellationToken: cancellationToken); + var normalizedCode = request.Code.Trim(); + var hasDuplicateCode = existingStores.Any(store => + store.Id != existing.Id && + string.Equals(store.Code, normalizedCode, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicateCode) + { + throw new BusinessException(ErrorCodes.Conflict, "门店编码已存在"); + } + + // 4. 更新门店字段 + existing.Code = normalizedCode; + existing.Name = request.Name.Trim(); + existing.Phone = request.ContactPhone.Trim(); + existing.ManagerName = request.ManagerName.Trim(); + existing.Address = request.Address.Trim(); + existing.CoverImageUrl = request.CoverImage?.Trim(); + existing.SignboardImageUrl = request.CoverImage?.Trim(); + existing.BusinessStatus = request.BusinessStatus ?? existing.BusinessStatus; + var serviceTypes = request.ServiceTypes?.Count > 0 + ? request.ServiceTypes + : [ServiceType.Delivery]; + StoreListMapping.ApplyServiceTypes(existing, serviceTypes); + + // 5. 保存修改 + await storeRepository.UpdateStoreAsync(existing, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreStatsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreStatsQuery.cs new file mode 100644 index 0000000..18a688b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreStatsQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 租户端门店统计查询。 +/// +public sealed record GetStoreStatsQuery : IRequest; + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs new file mode 100644 index 0000000..2a07798 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs @@ -0,0 +1,43 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 租户端门店列表查询。 +/// +public sealed record SearchStoresQuery : IRequest +{ + /// + /// 关键词(门店名称/编码/电话)。 + /// + public string? Keyword { get; init; } + + /// + /// 营业状态。 + /// + public StoreBusinessStatus? BusinessStatus { get; init; } + + /// + /// 审核状态。 + /// + public StoreAuditStatus? AuditStatus { get; init; } + + /// + /// 服务方式。 + /// + public ServiceType? ServiceType { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Services/StoreContextService.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Services/StoreContextService.cs new file mode 100644 index 0000000..9db497b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Services/StoreContextService.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Services; + +/// +/// 门店模块请求上下文服务。 +/// +public sealed class StoreContextService( + ICurrentUserAccessor currentUserAccessor, + ITenantProvider tenantProvider, + IHttpContextAccessor httpContextAccessor) +{ + /// + /// 读取当前请求所需的用户、租户与商户上下文。 + /// + /// 用户 ID、租户 ID、商户 ID。 + public (long UserId, long TenantId, long MerchantId) GetRequiredContext() + { + // 1. 校验登录用户 + var userId = currentUserAccessor.UserId; + if (userId <= 0) + { + throw new BusinessException(ErrorCodes.Unauthorized, "未登录或登录已过期"); + } + + // 2. 校验租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + if (tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); + } + + // 3. 解析商户上下文 + var merchantId = ResolveMerchantId(); + if (merchantId <= 0) + { + throw new BusinessException(ErrorCodes.Forbidden, "当前用户未绑定商户,无法访问门店管理"); + } + + // 4. 返回上下文 + return (userId, tenantId, merchantId); + } + + private long ResolveMerchantId() + { + // 1. 从 JWT Claim 读取商户标识 + var principal = httpContextAccessor.HttpContext?.User; + var merchantClaim = principal?.FindFirst("merchant_id")?.Value + ?? principal?.FindFirst(ClaimTypes.GroupSid)?.Value; + + // 2. 返回解析结果 + return long.TryParse(merchantClaim, out var merchantId) ? merchantId : 0L; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs new file mode 100644 index 0000000..846c09b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreListMapping.cs @@ -0,0 +1,121 @@ +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Application.App.Stores; + +/// +/// 租户端门店列表映射助手。 +/// +public static class StoreListMapping +{ + /// + /// 映射租户端门店列表项。 + /// + /// 门店实体。 + /// 列表项 DTO。 + public static StoreListItemDto ToListItemDto(Store store) => new() + { + Id = store.Id, + Name = store.Name, + Code = store.Code, + ContactPhone = store.Phone ?? string.Empty, + ManagerName = store.ManagerName ?? string.Empty, + Address = ResolveAddress(store), + CoverImage = string.IsNullOrWhiteSpace(store.CoverImageUrl) ? store.SignboardImageUrl : store.CoverImageUrl, + BusinessStatus = store.BusinessStatus, + AuditStatus = store.AuditStatus, + ServiceTypes = ResolveServiceTypes(store), + CreatedAt = store.CreatedAt + }; + + /// + /// 根据服务方式枚举映射门店开关字段。 + /// + /// 门店实体。 + /// 服务方式集合。 + public static void ApplyServiceTypes(Store store, IReadOnlyCollection serviceTypes) + { + // 1. 默认关闭全部服务开关 + store.SupportsDelivery = false; + store.SupportsPickup = false; + store.SupportsDineIn = false; + + // 2. 按传入服务方式逐项启用 + foreach (var serviceType in serviceTypes) + { + switch (serviceType) + { + case ServiceType.Delivery: + store.SupportsDelivery = true; + break; + case ServiceType.Pickup: + store.SupportsPickup = true; + break; + case ServiceType.DineIn: + store.SupportsDineIn = true; + break; + } + } + } + + /// + /// 判断门店是否满足服务方式过滤。 + /// + /// 门店实体。 + /// 服务方式。 + /// 满足返回 true。 + public static bool MatchServiceType(Store store, ServiceType serviceType) => + serviceType switch + { + ServiceType.Delivery => store.SupportsDelivery, + ServiceType.Pickup => store.SupportsPickup, + ServiceType.DineIn => store.SupportsDineIn, + _ => false + }; + + private static IReadOnlyList ResolveServiceTypes(Store store) + { + // 1. 聚合门店服务方式 + var result = new List(); + if (store.SupportsDelivery) + { + result.Add(ServiceType.Delivery); + } + + if (store.SupportsPickup) + { + result.Add(ServiceType.Pickup); + } + + if (store.SupportsDineIn) + { + result.Add(ServiceType.DineIn); + } + + // 2. 兜底默认展示配送服务 + if (result.Count == 0) + { + result.Add(ServiceType.Delivery); + } + + return result; + } + + private static string ResolveAddress(Store store) + { + // 1. 地址优先返回业务地址字段 + if (!string.IsNullOrWhiteSpace(store.Address)) + { + return store.Address; + } + + // 2. 兜底拼接省市区 + var parts = new[] { store.Province, store.City, store.District } + .Where(static part => !string.IsNullOrWhiteSpace(part)) + .ToArray(); + + return parts.Length == 0 ? string.Empty : string.Join(string.Empty, parts); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs new file mode 100644 index 0000000..dce91ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建门店命令验证器。 +/// +public sealed class CreateStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreCommandValidator() + { + // 1. 校验核心字段 + RuleFor(command => command.Name).NotEmpty().MaximumLength(128); + RuleFor(command => command.Code).NotEmpty().MaximumLength(32); + RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32); + RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64); + RuleFor(command => command.Address).NotEmpty().MaximumLength(256); + + // 2. 校验可选字段 + RuleFor(command => command.CoverImage).MaximumLength(500); + RuleFor(command => command.BusinessStatus).IsInEnum().When(command => command.BusinessStatus.HasValue); + + // 3. 校验服务方式列表 + RuleForEach(command => command.ServiceTypes).IsInEnum(); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreCommandValidator.cs new file mode 100644 index 0000000..f0ac620 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStoreCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 删除门店命令验证器。 +/// +public sealed class DeleteStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public DeleteStoreCommandValidator() + { + // 1. 校验门店标识 + RuleFor(command => command.Id).GreaterThan(0); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs new file mode 100644 index 0000000..4981955 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 门店列表查询验证器。 +/// +public sealed class SearchStoresQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchStoresQueryValidator() + { + // 1. 校验分页参数 + RuleFor(query => query.Page).GreaterThan(0); + RuleFor(query => query.PageSize).InclusiveBetween(1, 200); + + // 2. 校验字符串长度 + RuleFor(query => query.Keyword).MaximumLength(64); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs new file mode 100644 index 0000000..96a0be9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店命令验证器。 +/// +public sealed class UpdateStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreCommandValidator() + { + // 1. 校验标识与核心字段 + RuleFor(command => command.Id).GreaterThan(0); + RuleFor(command => command.Name).NotEmpty().MaximumLength(128); + RuleFor(command => command.Code).NotEmpty().MaximumLength(32); + RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32); + RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64); + RuleFor(command => command.Address).NotEmpty().MaximumLength(256); + + // 2. 校验可选字段 + RuleFor(command => command.CoverImage).MaximumLength(500); + RuleFor(command => command.BusinessStatus).IsInEnum().When(command => command.BusinessStatus.HasValue); + + // 3. 校验服务方式列表 + RuleForEach(command => command.ServiceTypes).IsInEnum(); + } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index 3f9d5df..cf11052 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -11,7 +11,11 @@ public interface IStoreRepository /// /// 依据标识获取门店。 /// - Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task FindByIdAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 获取指定商户的门店列表。 @@ -22,13 +26,14 @@ public interface IStoreRepository /// 按租户筛选门店列表。 /// Task> SearchAsync( - long tenantId, + long? tenantId, long? merchantId, StoreStatus? status, StoreAuditStatus? auditStatus, StoreBusinessStatus? businessStatus, StoreOwnershipType? ownershipType, string? keyword, + bool includeDeleted = false, CancellationToken cancellationToken = default); /// @@ -45,17 +50,25 @@ public interface IStoreRepository /// /// 获取指定商户集合的门店数量。 /// - Task> GetStoreCountsAsync(long tenantId, IReadOnlyCollection merchantIds, CancellationToken cancellationToken = default); + Task> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection merchantIds, CancellationToken cancellationToken = default); /// /// 获取门店营业时段。 /// - Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task> GetBusinessHoursAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 获取门店费用配置。 /// - Task GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task GetStoreFeeAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 新增门店费用配置。 @@ -70,7 +83,11 @@ public interface IStoreRepository /// /// 获取门店资质列表。 /// - Task> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task> GetQualificationsAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 依据标识获取门店资质。 @@ -110,22 +127,38 @@ public interface IStoreRepository /// /// 获取门店配送区域配置。 /// - Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task> GetDeliveryZonesAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 依据标识获取配送区域。 /// - Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + /// 配送区域 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 获取门店节假日配置。 /// - Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// 门店 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task> GetHolidaysAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 依据标识获取节假日配置。 /// - Task FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// 节假日配置 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + /// 是否包含已删除数据。 + Task FindHolidayByIdAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false); /// /// 获取门店桌台区域。 @@ -275,12 +308,18 @@ public interface IStoreRepository /// /// 删除配送区域。 /// - Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + /// 配送区域 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + Task DeleteDeliveryZoneAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default); /// /// 删除节假日。 /// - Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// 节假日配置 ID。 + /// 租户 ID(为空则不做租户过滤)。 + /// 取消标记。 + Task DeleteHolidayAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default); /// /// 删除桌台区域。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs new file mode 100644 index 0000000..d0ae633 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Infrastructure.App.Repositories; + +namespace TakeoutSaaS.Infrastructure.App.Extensions; + +/// +/// 门店模块基础设施注入扩展。 +/// +public static class AppServiceCollectionExtensions +{ + /// + /// 注册门店模块所需的 DbContext 与仓储。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // 1. 读取业务库连接串 + var connectionString = ResolveAppDatabaseConnectionString(configuration); + + // 2. 注册门店业务 DbContext + services.AddDbContext(options => + { + options.UseNpgsql(connectionString); + }); + + // 3. 注册门店仓储 + services.AddScoped(); + return services; + } + + private static string ResolveAppDatabaseConnectionString(IConfiguration configuration) + { + // 1. 优先读取新结构配置 + var writeConnection = configuration["Database:DataSources:AppDatabase:Write"]; + if (!string.IsNullOrWhiteSpace(writeConnection)) + { + return writeConnection; + } + + // 2. 兼容 ConnectionStrings 配置 + var fallbackConnection = configuration.GetConnectionString("AppDatabase"); + if (!string.IsNullOrWhiteSpace(fallbackConnection)) + { + return fallbackConnection; + } + + // 3. 未配置时抛出异常 + throw new InvalidOperationException("缺少业务库连接配置:Database:DataSources:AppDatabase:Write"); + } +} + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutTenantAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutTenantAppDbContext.cs new file mode 100644 index 0000000..fe2e4c2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutTenantAppDbContext.cs @@ -0,0 +1,306 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Infrastructure.App.Persistence; + +/// +/// 租户端门店模块业务 DbContext。 +/// +public sealed class TakeoutTenantAppDbContext( + DbContextOptions options, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) + : AppDbContext(options, currentUserAccessor, idGenerator) +{ + /// + /// 门店实体集合。 + /// + public DbSet Stores => Set(); + + /// + /// 门店费用集合。 + /// + public DbSet StoreFees => Set(); + + /// + /// 门店资质集合。 + /// + public DbSet StoreQualifications => Set(); + + /// + /// 门店审核记录集合。 + /// + public DbSet StoreAuditRecords => Set(); + + /// + /// 门店营业时段集合。 + /// + public DbSet StoreBusinessHours => Set(); + + /// + /// 门店节假日集合。 + /// + public DbSet StoreHolidays => Set(); + + /// + /// 门店配送区域集合。 + /// + public DbSet StoreDeliveryZones => Set(); + + /// + /// 门店桌台区域集合。 + /// + public DbSet StoreTableAreas => Set(); + + /// + /// 门店桌台集合。 + /// + public DbSet StoreTables => Set(); + + /// + /// 门店员工排班集合。 + /// + public DbSet StoreEmployeeShifts => Set(); + + /// + /// 门店自提配置集合。 + /// + public DbSet StorePickupSettings => Set(); + + /// + /// 门店自提时段集合。 + /// + public DbSet StorePickupSlots => Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // 1. 先应用基类配置(软删除、审计、注释) + base.OnModelCreating(modelBuilder); + + // 2. 配置门店模块映射 + ConfigureModel(modelBuilder); + } + + private static void ConfigureModel(ModelBuilder modelBuilder) + { + ConfigureStore(modelBuilder.Entity()); + ConfigureStoreFee(modelBuilder.Entity()); + ConfigureStoreQualification(modelBuilder.Entity()); + ConfigureStoreAuditRecord(modelBuilder.Entity()); + ConfigureStoreBusinessHour(modelBuilder.Entity()); + ConfigureStoreHoliday(modelBuilder.Entity()); + ConfigureStoreDeliveryZone(modelBuilder.Entity()); + ConfigureStoreTableArea(modelBuilder.Entity()); + ConfigureStoreTable(modelBuilder.Entity()); + ConfigureStoreEmployeeShift(modelBuilder.Entity()); + ConfigureStorePickupSetting(modelBuilder.Entity()); + ConfigureStorePickupSlot(modelBuilder.Entity()); + } + + private static void ConfigureStore(EntityTypeBuilder builder) + { + builder.ToTable("stores"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Code).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Phone).HasMaxLength(32); + builder.Property(x => x.ManagerName).HasMaxLength(64); + builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(50); + builder.Property(x => x.LegalRepresentative).HasMaxLength(100); + builder.Property(x => x.RegisteredAddress).HasMaxLength(500); + builder.Property(x => x.BusinessLicenseImageUrl).HasMaxLength(500); + builder.Property(x => x.SignboardImageUrl).HasMaxLength(500); + builder.Property(x => x.OwnershipType).HasConversion(); + builder.Property(x => x.AuditStatus).HasConversion(); + builder.Property(x => x.BusinessStatus).HasConversion(); + builder.Property(x => x.ClosureReason).HasConversion(); + builder.Property(x => x.ClosureReasonText).HasMaxLength(500); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.ForceCloseReason).HasMaxLength(500); + builder.Property(x => x.Province).HasMaxLength(64); + builder.Property(x => x.City).HasMaxLength(64); + builder.Property(x => x.District).HasMaxLength(64); + builder.Property(x => x.Address).HasMaxLength(256); + builder.Property(x => x.BusinessHours).HasMaxLength(256); + builder.Property(x => x.Announcement).HasMaxLength(512); + builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2); + builder.HasIndex(x => new { x.TenantId, x.MerchantId }); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.AuditStatus }); + builder.HasIndex(x => new { x.TenantId, x.BusinessStatus }); + builder.HasIndex(x => new { x.TenantId, x.OwnershipType }); + builder.HasIndex(x => new { x.Longitude, x.Latitude }) + .HasFilter("\"Longitude\" IS NOT NULL AND \"Latitude\" IS NOT NULL"); + builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber }) + .IsUnique() + .HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3"); + } + + private static void ConfigureStoreFee(EntityTypeBuilder builder) + { + builder.ToTable("store_fees"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2); + builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2); + builder.Property(x => x.PackagingFeeMode).HasConversion(); + builder.Property(x => x.OrderPackagingFeeMode).HasConversion(); + builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2); + builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text"); + builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureStoreQualification(EntityTypeBuilder builder) + { + builder.ToTable("store_qualifications"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.QualificationType).HasConversion(); + builder.Property(x => x.FileUrl).HasMaxLength(500).IsRequired(); + builder.Property(x => x.DocumentNumber).HasMaxLength(100); + builder.Property(x => x.IssuedAt).HasColumnType("date"); + builder.Property(x => x.ExpiresAt).HasColumnType("date"); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => x.ExpiresAt).HasFilter("\"ExpiresAt\" IS NOT NULL"); + builder.Ignore(x => x.IsExpired); + builder.Ignore(x => x.IsExpiringSoon); + } + + private static void ConfigureStoreAuditRecord(EntityTypeBuilder builder) + { + builder.ToTable("store_audit_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Action).HasConversion(); + builder.Property(x => x.PreviousStatus).HasConversion(); + builder.Property(x => x.NewStatus).HasConversion(); + builder.Property(x => x.OperatorName).HasMaxLength(100).IsRequired(); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.Remarks).HasMaxLength(1000); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => x.CreatedAt); + } + + private static void ConfigureStoreBusinessHour(EntityTypeBuilder builder) + { + builder.ToTable("store_business_hours"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.DayOfWeek).HasConversion(); + builder.Property(x => x.HourType).HasConversion(); + builder.Property(x => x.StartTime).HasColumnType("time"); + builder.Property(x => x.EndTime).HasColumnType("time"); + builder.Property(x => x.CapacityLimit); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.DayOfWeek }); + } + + private static void ConfigureStoreHoliday(EntityTypeBuilder builder) + { + builder.ToTable("store_holidays"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Date).HasColumnType("date"); + builder.Property(x => x.EndDate).HasColumnType("date"); + builder.Property(x => x.IsAllDay).HasDefaultValue(true); + builder.Property(x => x.StartTime).HasColumnType("time"); + builder.Property(x => x.EndTime).HasColumnType("time"); + builder.Property(x => x.OverrideType).HasConversion(); + builder.Property(x => x.IsClosed).HasDefaultValue(true); + builder.Property(x => x.Reason).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Date }); + } + + private static void ConfigureStoreDeliveryZone(EntityTypeBuilder builder) + { + builder.ToTable("store_delivery_zones"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ZoneName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.PolygonGeoJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2); + builder.Property(x => x.DeliveryFee).HasPrecision(10, 2); + builder.Property(x => x.EstimatedMinutes).IsRequired(); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + } + + private static void ConfigureStoreTableArea(EntityTypeBuilder builder) + { + builder.ToTable("store_table_areas"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique(); + } + + private static void ConfigureStoreTable(EntityTypeBuilder builder) + { + builder.ToTable("store_tables"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.AreaId).IsRequired(); + builder.Property(x => x.TableCode).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Capacity).HasDefaultValue(2); + builder.Property(x => x.Tags).HasMaxLength(256); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.QrCodeUrl).HasMaxLength(512); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.TableCode }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.AreaId }); + } + + private static void ConfigureStoreEmployeeShift(EntityTypeBuilder builder) + { + builder.ToTable("store_employee_shifts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.StaffId).IsRequired(); + builder.Property(x => x.ShiftDate).HasColumnType("date"); + builder.Property(x => x.StartTime).HasColumnType("time"); + builder.Property(x => x.EndTime).HasColumnType("time"); + builder.Property(x => x.RoleType).HasConversion(); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ShiftDate }); + } + + private static void ConfigureStorePickupSetting(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_settings"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.AllowToday).HasDefaultValue(true); + builder.Property(x => x.AllowDaysAhead).HasDefaultValue(3); + builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.MaxQuantityPerOrder); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + } + + private static void ConfigureStorePickupSlot(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_slots"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.StartTime).HasColumnType("time"); + builder.Property(x => x.EndTime).HasColumnType("time"); + builder.Property(x => x.CutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.Capacity).HasDefaultValue(0); + builder.Property(x => x.ReservedCount).HasDefaultValue(0); + builder.Property(x => x.Weekdays).HasMaxLength(32).HasDefaultValue("1,2,3,4,5,6,7"); + builder.Property(x => x.IsEnabled).HasDefaultValue(true); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.StartTime, x.EndTime }).IsUnique(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs new file mode 100644 index 0000000..a25d364 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -0,0 +1,801 @@ +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 门店聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStoreRepository +{ + /// + public Task FindByIdAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.Stores.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 返回门店实体 + return query + .Where(x => x.Id == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetByMerchantIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + return await context.Stores + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + } + + /// + public async Task> SearchAsync( + long? tenantId, + long? merchantId, + StoreStatus? status, + StoreAuditStatus? auditStatus, + StoreBusinessStatus? businessStatus, + StoreOwnershipType? ownershipType, + string? keyword, + bool includeDeleted = false, + CancellationToken cancellationToken = default) + { + var query = context.Stores.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 可选过滤:商户 + if (merchantId.HasValue) + { + query = query.Where(x => x.MerchantId == merchantId.Value); + } + + // 4. (空行后) 可选过滤:状态 + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + // 5. (空行后) 可选过滤:审核状态 + if (auditStatus.HasValue) + { + query = query.Where(x => x.AuditStatus == auditStatus.Value); + } + + // 6. (空行后) 可选过滤:经营状态 + if (businessStatus.HasValue) + { + query = query.Where(x => x.BusinessStatus == businessStatus.Value); + } + + // 7. (空行后) 可选过滤:主体类型 + if (ownershipType.HasValue) + { + query = query.Where(x => x.OwnershipType == ownershipType.Value); + } + + // 8. (空行后) 可选过滤:关键词 + if (!string.IsNullOrWhiteSpace(keyword)) + { + var trimmed = keyword.Trim(); + query = query.Where(x => + x.Name.Contains(trimmed) || + x.Code.Contains(trimmed) || + (x.Phone != null && x.Phone.Contains(trimmed))); + } + + // 9. (空行后) 查询并返回结果 + var stores = await query + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return stores; + } + + /// + public async Task ExistsStoreWithinDistanceAsync( + long merchantId, + long tenantId, + double longitude, + double latitude, + double distanceMeters, + CancellationToken cancellationToken = default) + { + // 1. 校验距离阈值 + if (distanceMeters <= 0) + { + return false; + } + + // 2. (空行后) 拉取候选坐标 + var coordinates = await context.Stores + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .Where(x => x.Longitude.HasValue && x.Latitude.HasValue) + .Select(x => new { Longitude = x.Longitude!.Value, Latitude = x.Latitude!.Value }) + .ToListAsync(cancellationToken); + + // 3. (空行后) 计算距离并判断是否命中 + foreach (var coordinate in coordinates) + { + var distance = CalculateDistanceMeters(latitude, longitude, coordinate.Latitude, coordinate.Longitude); + if (distance <= distanceMeters) + { + return true; + } + } + + // 4. (空行后) 返回未命中结果 + return false; + } + + /// + public async Task> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection merchantIds, CancellationToken cancellationToken = default) + { + if (merchantIds.Count == 0) + { + return new Dictionary(); + } + + var query = context.Stores.AsNoTracking(); + + // 1. 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 2. (空行后) 分组统计门店数量 + return await query + .Where(x => merchantIds.Contains(x.MerchantId)) + .GroupBy(x => x.MerchantId) + .Select(group => new { group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count, cancellationToken); + } + + /// + public async Task> GetBusinessHoursAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreBusinessHours.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 查询并返回营业时段 + var hours = await query + .Where(x => x.StoreId == storeId) + .OrderBy(x => x.DayOfWeek) + .ThenBy(x => x.StartTime) + .ToListAsync(cancellationToken); + + return hours; + } + + /// + public Task GetStoreFeeAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreFees.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 返回费用配置 + return query + .Where(x => x.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default) + { + return context.StoreFees.AddAsync(storeFee, cancellationToken).AsTask(); + } + + /// + public Task UpdateStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default) + { + context.StoreFees.Update(storeFee); + return Task.CompletedTask; + } + + /// + public async Task> GetQualificationsAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreQualifications.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 查询并返回资质列表 + var qualifications = await query + .Where(x => x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.QualificationType) + .ToListAsync(cancellationToken); + return qualifications; + } + + /// + public Task FindQualificationByIdAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreQualifications + .Where(x => x.TenantId == tenantId && x.Id == qualificationId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default) + { + return context.StoreQualifications.AddAsync(qualification, cancellationToken).AsTask(); + } + + /// + public Task UpdateQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default) + { + context.StoreQualifications.Update(qualification); + return Task.CompletedTask; + } + + /// + public async Task DeleteQualificationAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreQualifications + .Where(x => x.TenantId == tenantId && x.Id == qualificationId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreQualifications.Remove(existing); + } + } + + /// + public Task AddAuditRecordAsync(StoreAuditRecord record, CancellationToken cancellationToken = default) + { + return context.StoreAuditRecords.AddAsync(record, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditRecordsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var records = await context.StoreAuditRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + return records; + } + + /// + public Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetDeliveryZonesAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreDeliveryZones.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 查询并返回配送区域 + var zones = await query + .Where(x => x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return zones; + } + + /// + public Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreDeliveryZones.AsQueryable(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 返回配送区域实体 + return query + .Where(x => x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetHolidaysAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreHolidays.AsNoTracking(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 查询并返回节假日 + var holidays = await query + .Where(x => x.StoreId == storeId) + .OrderBy(x => x.Date) + .ToListAsync(cancellationToken); + + return holidays; + } + + /// + public Task FindHolidayByIdAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false) + { + var query = context.StoreHolidays.AsQueryable(); + + // 1. 包含软删除数据时忽略全局过滤 + if (includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. (空行后) 可选租户过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 3. (空行后) 返回节假日实体 + return query + .Where(x => x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var areas = await context.StoreTableAreas + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return areas; + } + + /// + public Task FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var tables = await context.StoreTables + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.TableCode) + .ToListAsync(cancellationToken); + + return tables; + } + + /// + public Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.TableCode == tableCode) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings.AddAsync(setting, cancellationToken).AsTask(); + } + + /// + public Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + context.StorePickupSettings.Update(setting); + return Task.CompletedTask; + } + + /// + public async Task> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var slots = await context.StorePickupSlots + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.StartTime) + .ToListAsync(cancellationToken); + return slots; + } + + /// + public Task FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSlotsAsync(IEnumerable slots, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots.AddRangeAsync(slots, cancellationToken); + } + + /// + public Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default) + { + context.StorePickupSlots.Update(slot); + return Task.CompletedTask; + } + + /// + public async Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default) + { + var query = context.StoreEmployeeShifts + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + + if (from.HasValue) + { + query = query.Where(x => x.ShiftDate >= from.Value.Date); + } + + if (to.HasValue) + { + query = query.Where(x => x.ShiftDate <= to.Value.Date); + } + + var shifts = await query + .OrderBy(x => x.ShiftDate) + .ThenBy(x => x.StartTime) + .ToListAsync(cancellationToken); + + return shifts; + } + + /// + public Task FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default) + { + return context.Stores.AddAsync(store, cancellationToken).AsTask(); + } + + /// + public Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default) + { + return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken); + } + + /// + public Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default) + { + context.StoreBusinessHours.Update(hour); + return Task.CompletedTask; + } + + /// + public Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default) + { + return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken); + } + + /// + public Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default) + { + context.StoreDeliveryZones.Update(zone); + return Task.CompletedTask; + } + + /// + public Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default) + { + return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken); + } + + /// + public Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default) + { + context.StoreHolidays.Update(holiday); + return Task.CompletedTask; + } + + /// + public Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default) + { + return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken); + } + + /// + public Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default) + { + context.StoreTableAreas.Update(area); + return Task.CompletedTask; + } + + /// + public Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default) + { + return context.StoreTables.AddRangeAsync(tables, cancellationToken); + } + + /// + public Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default) + { + context.StoreTables.Update(table); + return Task.CompletedTask; + } + + /// + public Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default) + { + return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken); + } + + /// + public Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default) + { + context.StoreEmployeeShifts.Update(shift); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreBusinessHours.Remove(existing); + } + } + + /// + public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default) + { + // 1. 查询目标配送区域 + var query = context.StoreDeliveryZones.AsQueryable(); + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 2. (空行后) 执行软删除 + var existing = await query + .Where(x => x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreDeliveryZones.Remove(existing); + } + } + + /// + public async Task DeleteHolidayAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default) + { + // 1. 查询目标节假日 + var query = context.StoreHolidays.AsQueryable(); + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + // 2. (空行后) 执行软删除 + var existing = await query + .Where(x => x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreHolidays.Remove(existing); + } + } + + /// + public async Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTableAreas.Remove(existing); + } + } + + /// + public async Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTables.Remove(existing); + } + } + + /// + public async Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StorePickupSlots.Remove(existing); + } + } + + /// + public async Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreEmployeeShifts.Remove(existing); + } + } + + /// + public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) + { + context.Stores.Update(store); + return Task.CompletedTask; + } + + /// + public async Task DeleteStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.Stores + .Where(x => x.TenantId == tenantId && x.Id == storeId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.Stores.Remove(existing); + } + + private static double CalculateDistanceMeters(double latitude1, double longitude1, double latitude2, double longitude2) + { + const double earthRadius = 6371000d; + var latRad1 = DegreesToRadians(latitude1); + var latRad2 = DegreesToRadians(latitude2); + var deltaLat = DegreesToRadians(latitude2 - latitude1); + var deltaLon = DegreesToRadians(longitude2 - longitude1); + var sinLat = Math.Sin(deltaLat / 2); + var sinLon = Math.Sin(deltaLon / 2); + var a = sinLat * sinLat + Math.Cos(latRad1) * Math.Cos(latRad2) * sinLon * sinLon; + var c = 2 * Math.Asin(Math.Min(1, Math.Sqrt(a))); + return earthRadius * c; + } + + private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180d); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs new file mode 100644 index 0000000..1f06200 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -0,0 +1,211 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Reflection; +using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 应用基础 DbContext,统一处理审计字段、软删除与全局查询过滤。 +/// +public abstract class AppDbContext( + DbContextOptions options, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) : DbContext(options) +{ + private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor; + private readonly IIdGenerator? _idGenerator = idGenerator; + + /// + /// 构建模型时应用软删除过滤器。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + ApplySoftDeleteQueryFilters(modelBuilder); + modelBuilder.ApplyXmlComments(); + } + + /// + /// 保存更改前应用元数据填充。 + /// + /// 受影响行数。 + public override int SaveChanges() + { + OnBeforeSaving(); + return base.SaveChanges(); + } + + /// + /// 异步保存更改前应用元数据填充。 + /// + /// 取消标记。 + /// 受影响行数。 + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + OnBeforeSaving(); + return base.SaveChangesAsync(cancellationToken); + } + + /// + /// 保存前处理审计、软删除等元数据,可在子类中扩展。 + /// + protected virtual void OnBeforeSaving() + { + ApplyIdGeneration(); + ApplySoftDeleteMetadata(); + ApplyAuditMetadata(); + } + + /// + /// 为新增实体生成雪花 ID。 + /// + private void ApplyIdGeneration() + { + if (_idGenerator == null) + { + return; + } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State != EntityState.Added) + { + continue; + } + + if (entry.Entity.Id == 0) + { + entry.Entity.Id = _idGenerator.NextId(); + } + } + } + + /// + /// 将软删除实体的删除操作转换为设置 DeletedAt。 + /// + private void ApplySoftDeleteMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity.DeletedAt.HasValue) + { + entry.Entity.DeletedAt = null; + } + + if (entry.State != EntityState.Deleted) + { + continue; + } + + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = utcNow; + if (entry.Entity is IAuditableEntity auditable) + { + auditable.DeletedBy = actor; + if (!auditable.UpdatedAt.HasValue) + { + auditable.UpdatedAt = utcNow; + auditable.UpdatedBy = actor; + } + } + } + } + + /// + /// 对审计实体填充创建与更新时间。 + /// + private void ApplyAuditMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = utcNow; + entry.Entity.UpdatedAt = null; + entry.Entity.CreatedBy ??= actor; + entry.Entity.UpdatedBy = null; + entry.Entity.DeletedBy = null; + entry.Entity.DeletedAt = null; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = utcNow; + entry.Entity.UpdatedBy = actor; + } + } + } + + private long? GetCurrentUserIdOrNull() + { + var userId = _currentUserAccessor?.UserId ?? 0; + return userId == 0 ? null : userId; + } + + /// + /// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。 + /// + /// 模型构建器。 + protected void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ISoftDeleteEntity).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + var methodInfo = typeof(AppDbContext) + .GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(entityType.ClrType); + + methodInfo.Invoke(this, new object[] { modelBuilder }); + } + } + + /// + /// 设置软删除查询过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 + private void SetSoftDeleteFilter(ModelBuilder modelBuilder) + where TEntity : class, ISoftDeleteEntity + { + modelBuilder.Entity().HasQueryFilter(entity => entity.DeletedAt == null); + } + + /// + /// 配置审计字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureAuditableEntity(EntityTypeBuilder builder) + where TEntity : class, IAuditableEntity + { + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.UpdatedAt); + builder.Property(x => x.DeletedAt); + builder.Property(x => x.CreatedBy); + builder.Property(x => x.UpdatedBy); + builder.Property(x => x.DeletedBy); + } + + /// + /// 配置软删除字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureSoftDeleteEntity(EntityTypeBuilder builder) + where TEntity : class, ISoftDeleteEntity + { + builder.Property(x => x.DeletedAt); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs new file mode 100644 index 0000000..810759e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Collections.Concurrent; +using System.Reflection; +using System.Xml.Linq; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// Applies XML documentation summaries to EF Core entities/columns as comments. +/// +internal static class ModelBuilderCommentExtensions +{ + /// + /// 将 XML 注释应用到实体与属性的 Comment。 + /// + /// 模型构建器。 + public static void ApplyXmlComments(this ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + ApplyEntityComment(entityType); + } + } + + private static void ApplyEntityComment(IMutableEntityType entityType) + { + var clrType = entityType.ClrType; + if (clrType == null) + { + return; + } + + if (XmlDocCommentProvider.TryGetSummary(clrType, out var typeComment)) + { + entityType.SetComment(typeComment); + } + + foreach (var property in entityType.GetProperties()) + { + var propertyInfo = property.PropertyInfo; + if (propertyInfo == null) + { + continue; + } + + if (XmlDocCommentProvider.TryGetSummary(propertyInfo, out var propertyComment)) + { + property.SetComment(propertyComment); + } + } + } + + private static class XmlDocCommentProvider + { + private static readonly ConcurrentDictionary> Cache = new(); + + /// + /// 尝试获取成员的摘要注释。 + /// + /// 反射成员。 + /// 输出的摘要文本。 + /// 存在摘要则返回 true。 + public static bool TryGetSummary(MemberInfo member, out string? summary) + { + summary = null; + var assembly = member switch + { + Type type => type.Assembly, + _ => member.DeclaringType?.Assembly + }; + + if (assembly == null) + { + return false; + } + + var map = Cache.GetOrAdd(assembly, LoadComments); + if (map.Count == 0) + { + return false; + } + + var key = GetMemberKey(member); + if (key == null || !map.TryGetValue(key, out var text)) + { + return false; + } + + summary = text; + return true; + } + + private static IReadOnlyDictionary LoadComments(Assembly assembly) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + var xmlPath = Path.ChangeExtension(assembly.Location, ".xml"); + if (string.IsNullOrWhiteSpace(xmlPath) || !File.Exists(xmlPath)) + { + return dictionary; + } + + var document = XDocument.Load(xmlPath); + foreach (var member in document.Descendants("member")) + { + var name = member.Attribute("name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var summary = member.Element("summary")?.Value; + if (string.IsNullOrWhiteSpace(summary)) + { + continue; + } + + var normalized = Normalize(summary); + if (!string.IsNullOrWhiteSpace(normalized)) + { + dictionary[name] = normalized; + } + } + + return dictionary; + } + + private static string? GetMemberKey(MemberInfo member) => + member switch + { + Type type => $"T:{GetFullName(type)}", + PropertyInfo property => $"P:{GetFullName(property.DeclaringType!)}.{property.Name}", + FieldInfo field => $"F:{GetFullName(field.DeclaringType!)}.{field.Name}", + _ => null + }; + + private static string GetFullName(Type type) => + (type.FullName ?? type.Name).Replace('+', '.'); + + private static string Normalize(string text) + { + var chars = text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); + return string.Join(' ', chars.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs new file mode 100644 index 0000000..b346321 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using TakeoutSaaS.Infrastructure.Identity.Options; + +namespace TakeoutSaaS.Infrastructure.Identity.Extensions; + +/// +/// JWT 认证扩展 +/// +public static class JwtAuthenticationExtensions +{ + /// + /// 配置 JWT Bearer 认证 + /// + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var jwtOptions = configuration.GetSection("Identity:Jwt").Get() + ?? throw new InvalidOperationException("缺少 Identity:Jwt 配置"); + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); + + services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + NameClaimType = ClaimTypes.NameIdentifier, + RoleClaimType = ClaimTypes.Role + }; + }); + + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs new file mode 100644 index 0000000..18aed1d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// JWT 配置选项。 +/// +public sealed class JwtOptions +{ + /// + /// 令牌颁发者(Issuer)。 + /// + [Required] + public string Issuer { get; set; } = string.Empty; + + /// + /// 令牌受众(Audience)。 + /// + [Required] + public string Audience { get; set; } = string.Empty; + + /// + /// JWT 签名密钥(至少 32 个字符)。 + /// + [Required] + [MinLength(32)] + public string Secret { get; set; } = string.Empty; + + /// + /// 访问令牌过期时间(分钟),范围:5-1440。 + /// + [Range(5, 1440)] + public int AccessTokenExpirationMinutes { get; set; } = 60; + + /// + /// 刷新令牌过期时间(分钟),范围:60-20160(14天)。 + /// + [Range(60, 1440 * 14)] + public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7; +}