feat: tenant门店管理首批接口落地
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s

This commit is contained in:
2026-02-17 11:10:06 +08:00
parent 992930a821
commit 654b1ae3f7
31 changed files with 2731 additions and 14 deletions

View File

@@ -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;
/// <summary>
/// 租户端门店管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/store")]
public sealed class StoreController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 查询门店列表。
/// </summary>
/// <param name="query">查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页列表。</returns>
[HttpGet("list")]
[ProducesResponseType(typeof(ApiResponse<StoreListResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<StoreListResultDto>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<StoreListResultDto>> List([FromQuery] SearchStoresQuery query, CancellationToken cancellationToken)
{
// 1. 查询门店分页
var result = await mediator.Send(query, cancellationToken);
// 2. 返回分页数据
return ApiResponse<StoreListResultDto>.Ok(result);
}
/// <summary>
/// 查询门店统计。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>统计结果。</returns>
[HttpGet("stats")]
[ProducesResponseType(typeof(ApiResponse<StoreStatsDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<StoreStatsDto>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<StoreStatsDto>> Stats(CancellationToken cancellationToken)
{
// 1. 查询门店统计
var result = await mediator.Send(new GetStoreStatsQuery(), cancellationToken);
// 2. 返回统计结果
return ApiResponse<StoreStatsDto>.Ok(result);
}
/// <summary>
/// 创建门店。
/// </summary>
/// <param name="command">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>创建结果。</returns>
[HttpPost("create")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<object>> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken)
{
// 1. 执行创建
await mediator.Send(command, cancellationToken);
// 2. 返回成功响应
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 更新门店。
/// </summary>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新结果。</returns>
[HttpPost("update")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<object>> Update([FromBody] UpdateStoreCommand command, CancellationToken cancellationToken)
{
// 1. 执行更新
await mediator.Send(command, cancellationToken);
// 2. 返回成功响应
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 删除门店。
/// </summary>
/// <param name="command">删除命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>删除结果。</returns>
[HttpPost("delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<object>> Delete([FromBody] DeleteStoreCommand command, CancellationToken cancellationToken)
{
// 1. 执行删除
await mediator.Send(command, cancellationToken);
// 2. 返回成功响应
return ApiResponse<object>.Ok(null);
}
}

View File

@@ -9,7 +9,11 @@ using OpenTelemetry.Metrics;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
using Serilog; 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.Authorization.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Security; using TakeoutSaaS.Shared.Web.Security;
using TakeoutSaaS.Shared.Web.Swagger; using TakeoutSaaS.Shared.Web.Swagger;
@@ -77,6 +81,10 @@ if (isDevelopment)
} }
// 5. 注册鉴权授权与权限策略 // 5. 注册鉴权授权与权限策略
builder.Services.AddAppApplication();
builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddTenantResolution(builder.Configuration);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddPermissionAuthorization(); builder.Services.AddPermissionAuthorization();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
@@ -150,10 +158,14 @@ app.UseCors("TenantApiCors");
// 1. (空行后) 通用 Web Core 中间件异常、ProblemDetails、日志等 // 1. (空行后) 通用 Web Core 中间件异常、ProblemDetails、日志等
app.UseSharedWebCore(); app.UseSharedWebCore();
// 2. (空行后) 执行授权 // 2. (空行后) 执行认证并解析租户
app.UseAuthentication();
app.UseTenantResolution();
// 3. (空行后) 执行授权
app.UseAuthorization(); app.UseAuthorization();
// 3. (空行后) 开发环境启用 Swagger // 4. (空行后) 开发环境启用 Swagger
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSharedSwagger(); app.UseSharedSwagger();

View File

@@ -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;
/// <summary>
/// 业务应用层服务注册。
/// </summary>
public static class AppApplicationServiceCollectionExtensions
{
/// <summary>
/// 注册业务应用层MediatR、验证器、管道行为
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
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<StoreContextService>();
return services;
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 租户端创建门店命令。
/// </summary>
public sealed record CreateStoreCommand : IRequest
{
/// <summary>
/// 门店名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 负责人。
/// </summary>
public string ManagerName { get; init; } = string.Empty;
/// <summary>
/// 门店地址。
/// </summary>
public string Address { get; init; } = string.Empty;
/// <summary>
/// 门店封面图。
/// </summary>
public string? CoverImage { get; init; }
/// <summary>
/// 营业状态。
/// </summary>
public StoreBusinessStatus? BusinessStatus { get; init; }
/// <summary>
/// 服务方式。
/// </summary>
public IReadOnlyList<ServiceType>? ServiceTypes { get; init; }
}

View File

@@ -0,0 +1,18 @@
using MediatR;
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Stores.Commands;
/// <summary>
/// 租户端删除门店命令。
/// </summary>
public sealed record DeleteStoreCommand : IRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
}

View File

@@ -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;
/// <summary>
/// 租户端更新门店命令。
/// </summary>
public sealed record UpdateStoreCommand : IRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 负责人。
/// </summary>
public string ManagerName { get; init; } = string.Empty;
/// <summary>
/// 门店地址。
/// </summary>
public string Address { get; init; } = string.Empty;
/// <summary>
/// 门店封面图。
/// </summary>
public string? CoverImage { get; init; }
/// <summary>
/// 营业状态。
/// </summary>
public StoreBusinessStatus? BusinessStatus { get; init; }
/// <summary>
/// 服务方式。
/// </summary>
public IReadOnlyList<ServiceType>? ServiceTypes { get; init; }
}

View File

@@ -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;
/// <summary>
/// 租户端门店列表项。
/// </summary>
public sealed record StoreListItemDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
/// <summary>
/// 负责人。
/// </summary>
public string ManagerName { get; init; } = string.Empty;
/// <summary>
/// 门店地址。
/// </summary>
public string Address { get; init; } = string.Empty;
/// <summary>
/// 门店封面图。
/// </summary>
public string? CoverImage { get; init; }
/// <summary>
/// 营业状态。
/// </summary>
public StoreBusinessStatus BusinessStatus { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public StoreAuditStatus AuditStatus { get; init; }
/// <summary>
/// 服务方式。
/// </summary>
public IReadOnlyList<ServiceType> ServiceTypes { get; init; } = [];
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 租户端门店列表分页结果。
/// </summary>
public sealed record StoreListResultDto
{
/// <summary>
/// 当前页数据。
/// </summary>
public IReadOnlyList<StoreListItemDto> Items { get; init; } = [];
/// <summary>
/// 总条数。
/// </summary>
public int Total { get; init; }
/// <summary>
/// 当前页码(从 1 开始)。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace TakeoutSaaS.Application.App.Stores.Dto;
/// <summary>
/// 租户端门店统计信息。
/// </summary>
public sealed record StoreStatsDto
{
/// <summary>
/// 门店总数。
/// </summary>
public int Total { get; init; }
/// <summary>
/// 营业中数量。
/// </summary>
public int Operating { get; init; }
/// <summary>
/// 休息中数量。
/// </summary>
public int Resting { get; init; }
/// <summary>
/// 待审核数量。
/// </summary>
public int PendingAudit { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Application.App.Stores.Enums;
/// <summary>
/// 门店服务方式。
/// </summary>
public enum ServiceType
{
/// <summary>
/// 外卖配送。
/// </summary>
Delivery = 1,
/// <summary>
/// 到店自提。
/// </summary>
Pickup = 2,
/// <summary>
/// 堂食。
/// </summary>
DineIn = 3
}

View File

@@ -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;
/// <summary>
/// 租户端创建门店命令处理器。
/// </summary>
public sealed class CreateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
: IRequestHandler<CreateStoreCommand>
{
/// <inheritdoc />
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);
}
}

View File

@@ -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;
/// <summary>
/// 租户端删除门店命令处理器。
/// </summary>
public sealed class DeleteStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
: IRequestHandler<DeleteStoreCommand>
{
/// <inheritdoc />
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);
}
}

View File

@@ -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;
/// <summary>
/// 租户端门店统计查询处理器。
/// </summary>
public sealed class GetStoreStatsQueryHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
: IRequestHandler<GetStoreStatsQuery, StoreStatsDto>
{
/// <inheritdoc />
public async Task<StoreStatsDto> 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)
};
}
}

View File

@@ -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;
/// <summary>
/// 租户端门店列表查询处理器。
/// </summary>
public sealed class SearchStoresQueryHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
: IRequestHandler<SearchStoresQuery, StoreListResultDto>
{
/// <inheritdoc />
public async Task<StoreListResultDto> 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
};
}
}

View File

@@ -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;
/// <summary>
/// 租户端更新门店命令处理器。
/// </summary>
public sealed class UpdateStoreCommandHandler(
StoreContextService storeContextService,
IStoreRepository storeRepository)
: IRequestHandler<UpdateStoreCommand>
{
/// <inheritdoc />
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);
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.Stores.Queries;
/// <summary>
/// 租户端门店统计查询。
/// </summary>
public sealed record GetStoreStatsQuery : IRequest<StoreStatsDto>;

View File

@@ -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;
/// <summary>
/// 租户端门店列表查询。
/// </summary>
public sealed record SearchStoresQuery : IRequest<StoreListResultDto>
{
/// <summary>
/// 关键词(门店名称/编码/电话)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 营业状态。
/// </summary>
public StoreBusinessStatus? BusinessStatus { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public StoreAuditStatus? AuditStatus { get; init; }
/// <summary>
/// 服务方式。
/// </summary>
public ServiceType? ServiceType { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -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;
/// <summary>
/// 门店模块请求上下文服务。
/// </summary>
public sealed class StoreContextService(
ICurrentUserAccessor currentUserAccessor,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
/// <summary>
/// 读取当前请求所需的用户、租户与商户上下文。
/// </summary>
/// <returns>用户 ID、租户 ID、商户 ID。</returns>
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;
}
}

View File

@@ -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;
/// <summary>
/// 租户端门店列表映射助手。
/// </summary>
public static class StoreListMapping
{
/// <summary>
/// 映射租户端门店列表项。
/// </summary>
/// <param name="store">门店实体。</param>
/// <returns>列表项 DTO。</returns>
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
};
/// <summary>
/// 根据服务方式枚举映射门店开关字段。
/// </summary>
/// <param name="store">门店实体。</param>
/// <param name="serviceTypes">服务方式集合。</param>
public static void ApplyServiceTypes(Store store, IReadOnlyCollection<ServiceType> 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;
}
}
}
/// <summary>
/// 判断门店是否满足服务方式过滤。
/// </summary>
/// <param name="store">门店实体。</param>
/// <param name="serviceType">服务方式。</param>
/// <returns>满足返回 true。</returns>
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<ServiceType> ResolveServiceTypes(Store store)
{
// 1. 聚合门店服务方式
var result = new List<ServiceType>();
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);
}
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 创建门店命令验证器。
/// </summary>
public sealed class CreateStoreCommandValidator : AbstractValidator<CreateStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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();
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 删除门店命令验证器。
/// </summary>
public sealed class DeleteStoreCommandValidator : AbstractValidator<DeleteStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public DeleteStoreCommandValidator()
{
// 1. 校验门店标识
RuleFor(command => command.Id).GreaterThan(0);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Queries;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 门店列表查询验证器。
/// </summary>
public sealed class SearchStoresQueryValidator : AbstractValidator<SearchStoresQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchStoresQueryValidator()
{
// 1. 校验分页参数
RuleFor(query => query.Page).GreaterThan(0);
RuleFor(query => query.PageSize).InclusiveBetween(1, 200);
// 2. 校验字符串长度
RuleFor(query => query.Keyword).MaximumLength(64);
}
}

View File

@@ -0,0 +1,32 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Stores.Commands;
namespace TakeoutSaaS.Application.App.Stores.Validators;
/// <summary>
/// 更新门店命令验证器。
/// </summary>
public sealed class UpdateStoreCommandValidator : AbstractValidator<UpdateStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
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();
}
}

View File

@@ -11,7 +11,11 @@ public interface IStoreRepository
/// <summary> /// <summary>
/// 依据标识获取门店。 /// 依据标识获取门店。
/// </summary> /// </summary>
Task<Store?> FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<Store?> FindByIdAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 获取指定商户的门店列表。 /// 获取指定商户的门店列表。
@@ -22,13 +26,14 @@ public interface IStoreRepository
/// 按租户筛选门店列表。 /// 按租户筛选门店列表。
/// </summary> /// </summary>
Task<IReadOnlyList<Store>> SearchAsync( Task<IReadOnlyList<Store>> SearchAsync(
long tenantId, long? tenantId,
long? merchantId, long? merchantId,
StoreStatus? status, StoreStatus? status,
StoreAuditStatus? auditStatus, StoreAuditStatus? auditStatus,
StoreBusinessStatus? businessStatus, StoreBusinessStatus? businessStatus,
StoreOwnershipType? ownershipType, StoreOwnershipType? ownershipType,
string? keyword, string? keyword,
bool includeDeleted = false,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@@ -45,17 +50,25 @@ public interface IStoreRepository
/// <summary> /// <summary>
/// 获取指定商户集合的门店数量。 /// 获取指定商户集合的门店数量。
/// </summary> /// </summary>
Task<Dictionary<long, int>> GetStoreCountsAsync(long tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default); Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取门店营业时段。 /// 获取门店营业时段。
/// </summary> /// </summary>
Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 获取门店费用配置。 /// 获取门店费用配置。
/// </summary> /// </summary>
Task<StoreFee?> GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<StoreFee?> GetStoreFeeAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 新增门店费用配置。 /// 新增门店费用配置。
@@ -70,7 +83,11 @@ public interface IStoreRepository
/// <summary> /// <summary>
/// 获取门店资质列表。 /// 获取门店资质列表。
/// </summary> /// </summary>
Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 依据标识获取门店资质。 /// 依据标识获取门店资质。
@@ -110,22 +127,38 @@ public interface IStoreRepository
/// <summary> /// <summary>
/// 获取门店配送区域配置。 /// 获取门店配送区域配置。
/// </summary> /// </summary>
Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 依据标识获取配送区域。 /// 依据标识获取配送区域。
/// </summary> /// </summary>
Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); /// <param name="deliveryZoneId">配送区域 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 获取门店节假日配置。 /// 获取门店节假日配置。
/// </summary> /// </summary>
Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// <param name="storeId">门店 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 依据标识获取节假日配置。 /// 依据标识获取节假日配置。
/// </summary> /// </summary>
Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); /// <param name="holidayId">节假日配置 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false);
/// <summary> /// <summary>
/// 获取门店桌台区域。 /// 获取门店桌台区域。
@@ -275,12 +308,18 @@ public interface IStoreRepository
/// <summary> /// <summary>
/// 删除配送区域。 /// 删除配送区域。
/// </summary> /// </summary>
Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); /// <param name="deliveryZoneId">配送区域 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
Task DeleteDeliveryZoneAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 删除节假日。 /// 删除节假日。
/// </summary> /// </summary>
Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); /// <param name="holidayId">节假日配置 ID。</param>
/// <param name="tenantId">租户 ID为空则不做租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
Task DeleteHolidayAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 删除桌台区域。 /// 删除桌台区域。

View File

@@ -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;
/// <summary>
/// 门店模块基础设施注入扩展。
/// </summary>
public static class AppServiceCollectionExtensions
{
/// <summary>
/// 注册门店模块所需的 DbContext 与仓储。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// 1. 读取业务库连接串
var connectionString = ResolveAppDatabaseConnectionString(configuration);
// 2. 注册门店业务 DbContext
services.AddDbContext<TakeoutTenantAppDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
// 3. 注册门店仓储
services.AddScoped<IStoreRepository, EfStoreRepository>();
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");
}
}

View File

@@ -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;
/// <summary>
/// 租户端门店模块业务 DbContext。
/// </summary>
public sealed class TakeoutTenantAppDbContext(
DbContextOptions<TakeoutTenantAppDbContext> options,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null)
: AppDbContext(options, currentUserAccessor, idGenerator)
{
/// <summary>
/// 门店实体集合。
/// </summary>
public DbSet<Store> Stores => Set<Store>();
/// <summary>
/// 门店费用集合。
/// </summary>
public DbSet<StoreFee> StoreFees => Set<StoreFee>();
/// <summary>
/// 门店资质集合。
/// </summary>
public DbSet<StoreQualification> StoreQualifications => Set<StoreQualification>();
/// <summary>
/// 门店审核记录集合。
/// </summary>
public DbSet<StoreAuditRecord> StoreAuditRecords => Set<StoreAuditRecord>();
/// <summary>
/// 门店营业时段集合。
/// </summary>
public DbSet<StoreBusinessHour> StoreBusinessHours => Set<StoreBusinessHour>();
/// <summary>
/// 门店节假日集合。
/// </summary>
public DbSet<StoreHoliday> StoreHolidays => Set<StoreHoliday>();
/// <summary>
/// 门店配送区域集合。
/// </summary>
public DbSet<StoreDeliveryZone> StoreDeliveryZones => Set<StoreDeliveryZone>();
/// <summary>
/// 门店桌台区域集合。
/// </summary>
public DbSet<StoreTableArea> StoreTableAreas => Set<StoreTableArea>();
/// <summary>
/// 门店桌台集合。
/// </summary>
public DbSet<StoreTable> StoreTables => Set<StoreTable>();
/// <summary>
/// 门店员工排班集合。
/// </summary>
public DbSet<StoreEmployeeShift> StoreEmployeeShifts => Set<StoreEmployeeShift>();
/// <summary>
/// 门店自提配置集合。
/// </summary>
public DbSet<StorePickupSetting> StorePickupSettings => Set<StorePickupSetting>();
/// <summary>
/// 门店自提时段集合。
/// </summary>
public DbSet<StorePickupSlot> StorePickupSlots => Set<StorePickupSlot>();
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 1. 先应用基类配置(软删除、审计、注释)
base.OnModelCreating(modelBuilder);
// 2. 配置门店模块映射
ConfigureModel(modelBuilder);
}
private static void ConfigureModel(ModelBuilder modelBuilder)
{
ConfigureStore(modelBuilder.Entity<Store>());
ConfigureStoreFee(modelBuilder.Entity<StoreFee>());
ConfigureStoreQualification(modelBuilder.Entity<StoreQualification>());
ConfigureStoreAuditRecord(modelBuilder.Entity<StoreAuditRecord>());
ConfigureStoreBusinessHour(modelBuilder.Entity<StoreBusinessHour>());
ConfigureStoreHoliday(modelBuilder.Entity<StoreHoliday>());
ConfigureStoreDeliveryZone(modelBuilder.Entity<StoreDeliveryZone>());
ConfigureStoreTableArea(modelBuilder.Entity<StoreTableArea>());
ConfigureStoreTable(modelBuilder.Entity<StoreTable>());
ConfigureStoreEmployeeShift(modelBuilder.Entity<StoreEmployeeShift>());
ConfigureStorePickupSetting(modelBuilder.Entity<StorePickupSetting>());
ConfigureStorePickupSlot(modelBuilder.Entity<StorePickupSlot>());
}
private static void ConfigureStore(EntityTypeBuilder<Store> 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<int>();
builder.Property(x => x.AuditStatus).HasConversion<int>();
builder.Property(x => x.BusinessStatus).HasConversion<int>();
builder.Property(x => x.ClosureReason).HasConversion<int?>();
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<StoreFee> 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<int>();
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
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<StoreQualification> builder)
{
builder.ToTable("store_qualifications");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.QualificationType).HasConversion<int>();
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<StoreAuditRecord> builder)
{
builder.ToTable("store_audit_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Action).HasConversion<int>();
builder.Property(x => x.PreviousStatus).HasConversion<int?>();
builder.Property(x => x.NewStatus).HasConversion<int>();
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<StoreBusinessHour> builder)
{
builder.ToTable("store_business_hours");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.DayOfWeek).HasConversion<int>();
builder.Property(x => x.HourType).HasConversion<int>();
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<StoreHoliday> 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<int>();
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<StoreDeliveryZone> 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<StoreTableArea> 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<StoreTable> 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<int>();
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<StoreEmployeeShift> 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<int>();
builder.Property(x => x.Notes).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ShiftDate });
}
private static void ConfigureStorePickupSetting(EntityTypeBuilder<StorePickupSetting> 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<StorePickupSlot> 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();
}
}

View File

@@ -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;
/// <summary>
/// 门店聚合的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStoreRepository
{
/// <inheritdoc />
public Task<Store?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> 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;
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
{
if (merchantIds.Count == 0)
{
return new Dictionary<long, int>();
}
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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreBusinessHour>> 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;
}
/// <inheritdoc />
public Task<StoreFee?> 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);
}
/// <inheritdoc />
public Task AddStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default)
{
return context.StoreFees.AddAsync(storeFee, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default)
{
context.StoreFees.Update(storeFee);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreQualification>> 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;
}
/// <inheritdoc />
public Task<StoreQualification?> FindQualificationByIdAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreQualifications
.Where(x => x.TenantId == tenantId && x.Id == qualificationId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default)
{
return context.StoreQualifications.AddAsync(qualification, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default)
{
context.StoreQualifications.Update(qualification);
return Task.CompletedTask;
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public Task AddAuditRecordAsync(StoreAuditRecord record, CancellationToken cancellationToken = default)
{
return context.StoreAuditRecords.AddAsync(record, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreAuditRecord>> 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;
}
/// <inheritdoc />
public Task<StoreBusinessHour?> FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreBusinessHours
.Where(x => x.TenantId == tenantId && x.Id == businessHourId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreDeliveryZone>> 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;
}
/// <inheritdoc />
public Task<StoreDeliveryZone?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreHoliday>> 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;
}
/// <inheritdoc />
public Task<StoreHoliday?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreTableArea>> 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;
}
/// <inheritdoc />
public Task<StoreTableArea?> FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreTableAreas
.Where(x => x.TenantId == tenantId && x.Id == areaId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreTable>> 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;
}
/// <inheritdoc />
public Task<StoreTable?> FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreTables
.Where(x => x.TenantId == tenantId && x.Id == tableId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<StoreTable?> FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreTables
.Where(x => x.TenantId == tenantId && x.TableCode == tableCode)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<StorePickupSetting?> GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StorePickupSettings
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default)
{
return context.StorePickupSettings.AddAsync(setting, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default)
{
context.StorePickupSettings.Update(setting);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<StorePickupSlot>> 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;
}
/// <inheritdoc />
public Task<StorePickupSlot?> FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StorePickupSlots
.Where(x => x.TenantId == tenantId && x.Id == slotId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddPickupSlotsAsync(IEnumerable<StorePickupSlot> slots, CancellationToken cancellationToken = default)
{
return context.StorePickupSlots.AddRangeAsync(slots, cancellationToken);
}
/// <inheritdoc />
public Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default)
{
context.StorePickupSlots.Update(slot);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreEmployeeShift>> 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;
}
/// <inheritdoc />
public Task<StoreEmployeeShift?> FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default)
{
return context.StoreEmployeeShifts
.Where(x => x.TenantId == tenantId && x.Id == shiftId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default)
{
return context.Stores.AddAsync(store, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddBusinessHoursAsync(IEnumerable<StoreBusinessHour> hours, CancellationToken cancellationToken = default)
{
return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken);
}
/// <inheritdoc />
public Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default)
{
context.StoreBusinessHours.Update(hour);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddDeliveryZonesAsync(IEnumerable<StoreDeliveryZone> zones, CancellationToken cancellationToken = default)
{
return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default)
{
context.StoreDeliveryZones.Update(zone);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddHolidaysAsync(IEnumerable<StoreHoliday> holidays, CancellationToken cancellationToken = default)
{
return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken);
}
/// <inheritdoc />
public Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default)
{
context.StoreHolidays.Update(holiday);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddTableAreasAsync(IEnumerable<StoreTableArea> areas, CancellationToken cancellationToken = default)
{
return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default)
{
context.StoreTableAreas.Update(area);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddTablesAsync(IEnumerable<StoreTable> tables, CancellationToken cancellationToken = default)
{
return context.StoreTables.AddRangeAsync(tables, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default)
{
context.StoreTables.Update(table);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddShiftsAsync(IEnumerable<StoreEmployeeShift> shifts, CancellationToken cancellationToken = default)
{
return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken);
}
/// <inheritdoc />
public Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default)
{
context.StoreEmployeeShifts.Update(shift);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default)
{
context.Stores.Update(store);
return Task.CompletedTask;
}
/// <inheritdoc />
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);
}

View File

@@ -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;
/// <summary>
/// 应用基础 DbContext统一处理审计字段、软删除与全局查询过滤。
/// </summary>
public abstract class AppDbContext(
DbContextOptions options,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null) : DbContext(options)
{
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
private readonly IIdGenerator? _idGenerator = idGenerator;
/// <summary>
/// 构建模型时应用软删除过滤器。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ApplySoftDeleteQueryFilters(modelBuilder);
modelBuilder.ApplyXmlComments();
}
/// <summary>
/// 保存更改前应用元数据填充。
/// </summary>
/// <returns>受影响行数。</returns>
public override int SaveChanges()
{
OnBeforeSaving();
return base.SaveChanges();
}
/// <summary>
/// 异步保存更改前应用元数据填充。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>受影响行数。</returns>
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
OnBeforeSaving();
return base.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 保存前处理审计、软删除等元数据,可在子类中扩展。
/// </summary>
protected virtual void OnBeforeSaving()
{
ApplyIdGeneration();
ApplySoftDeleteMetadata();
ApplyAuditMetadata();
}
/// <summary>
/// 为新增实体生成雪花 ID。
/// </summary>
private void ApplyIdGeneration()
{
if (_idGenerator == null)
{
return;
}
foreach (var entry in ChangeTracker.Entries<EntityBase>())
{
if (entry.State != EntityState.Added)
{
continue;
}
if (entry.Entity.Id == 0)
{
entry.Entity.Id = _idGenerator.NextId();
}
}
}
/// <summary>
/// 将软删除实体的删除操作转换为设置 DeletedAt。
/// </summary>
private void ApplySoftDeleteMetadata()
{
var utcNow = DateTime.UtcNow;
var actor = GetCurrentUserIdOrNull();
foreach (var entry in ChangeTracker.Entries<ISoftDeleteEntity>())
{
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;
}
}
}
}
/// <summary>
/// 对审计实体填充创建与更新时间。
/// </summary>
private void ApplyAuditMetadata()
{
var utcNow = DateTime.UtcNow;
var actor = GetCurrentUserIdOrNull();
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
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;
}
/// <summary>
/// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
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 });
}
}
/// <summary>
/// 设置软删除查询过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, ISoftDeleteEntity
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
}
/// <summary>
/// 配置审计字段的通用约束。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="builder">实体构建器。</param>
protected static void ConfigureAuditableEntity<TEntity>(EntityTypeBuilder<TEntity> 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);
}
/// <summary>
/// 配置软删除字段的通用约束。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="builder">实体构建器。</param>
protected static void ConfigureSoftDeleteEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
where TEntity : class, ISoftDeleteEntity
{
builder.Property(x => x.DeletedAt);
}
}

View File

@@ -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;
/// <summary>
/// Applies XML documentation summaries to EF Core entities/columns as comments.
/// </summary>
internal static class ModelBuilderCommentExtensions
{
/// <summary>
/// 将 XML 注释应用到实体与属性的 Comment。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
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<Assembly, IReadOnlyDictionary<string, string>> Cache = new();
/// <summary>
/// 尝试获取成员的摘要注释。
/// </summary>
/// <param name="member">反射成员。</param>
/// <param name="summary">输出的摘要文本。</param>
/// <returns>存在摘要则返回 true。</returns>
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<string, string> LoadComments(Assembly assembly)
{
var dictionary = new Dictionary<string, string>(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));
}
}
}

View File

@@ -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;
/// <summary>
/// JWT 认证扩展
/// </summary>
public static class JwtAuthenticationExtensions
{
/// <summary>
/// 配置 JWT Bearer 认证
/// </summary>
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var jwtOptions = configuration.GetSection("Identity:Jwt").Get<JwtOptions>()
?? 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;
}
}

View File

@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// JWT 配置选项。
/// </summary>
public sealed class JwtOptions
{
/// <summary>
/// 令牌颁发者Issuer
/// </summary>
[Required]
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// 令牌受众Audience
/// </summary>
[Required]
public string Audience { get; set; } = string.Empty;
/// <summary>
/// JWT 签名密钥(至少 32 个字符)。
/// </summary>
[Required]
[MinLength(32)]
public string Secret { get; set; } = string.Empty;
/// <summary>
/// 访问令牌过期时间分钟范围5-1440。
/// </summary>
[Range(5, 1440)]
public int AccessTokenExpirationMinutes { get; set; } = 60;
/// <summary>
/// 刷新令牌过期时间分钟范围60-2016014天
/// </summary>
[Range(60, 1440 * 14)]
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
}