feat: tenant门店管理首批接口落地
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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-20160(14天)。
|
||||
/// </summary>
|
||||
[Range(60, 1440 * 14)]
|
||||
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
|
||||
}
|
||||
Reference in New Issue
Block a user