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,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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user