feat(member): implement points mall backend module

This commit is contained in:
2026-03-04 12:15:18 +08:00
parent 2970134200
commit bd418c5927
53 changed files with 5193 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 修改积分商城商品状态命令。
/// </summary>
public sealed class ChangePointMallProductStatusCommand : IRequest<MemberPointMallProductDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "disabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 删除积分商城商品命令。
/// </summary>
public sealed class DeletePointMallProductCommand : IRequest
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; init; }
}

View File

@@ -0,0 +1,95 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 保存积分商城兑换商品命令。
/// </summary>
public sealed class SavePointMallProductCommand : IRequest<MemberPointMallProductDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 积分商城商品标识(编辑时传)。
/// </summary>
public long? PointMallProductId { get; init; }
/// <summary>
/// 展示名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 展示图片。
/// </summary>
public string? ImageUrl { get; init; }
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string RedeemType { get; init; } = "product";
/// <summary>
/// 关联商品 ID。
/// </summary>
public long? ProductId { get; init; }
/// <summary>
/// 关联优惠券模板 ID。
/// </summary>
public long? CouponTemplateId { get; init; }
/// <summary>
/// 实物名称。
/// </summary>
public string? PhysicalName { get; init; }
/// <summary>
/// 领取方式store_pickup/delivery
/// </summary>
public string? PickupMethod { get; init; }
/// <summary>
/// 商品描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 兑换方式points/mixed
/// </summary>
public string ExchangeType { get; init; } = "points";
/// <summary>
/// 所需积分。
/// </summary>
public int RequiredPoints { get; init; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; init; }
/// <summary>
/// 库存总量。
/// </summary>
public int StockTotal { get; init; }
/// <summary>
/// 每人限兑次数null 表示不限)。
/// </summary>
public int? PerMemberLimit { get; init; }
/// <summary>
/// 到账通知渠道。
/// </summary>
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,65 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 保存积分商城规则命令。
/// </summary>
public sealed class SavePointMallRuleCommand : IRequest<MemberPointMallRuleDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 是否启用消费获取。
/// </summary>
public bool IsConsumeRewardEnabled { get; init; }
/// <summary>
/// 每消费多少元触发一次积分计算。
/// </summary>
public int ConsumeAmountPerStep { get; init; }
/// <summary>
/// 每步获得积分。
/// </summary>
public int ConsumeRewardPointsPerStep { get; init; }
/// <summary>
/// 是否启用评价奖励。
/// </summary>
public bool IsReviewRewardEnabled { get; init; }
/// <summary>
/// 评价奖励积分。
/// </summary>
public int ReviewRewardPoints { get; init; }
/// <summary>
/// 是否启用注册奖励。
/// </summary>
public bool IsRegisterRewardEnabled { get; init; }
/// <summary>
/// 注册奖励积分。
/// </summary>
public int RegisterRewardPoints { get; init; }
/// <summary>
/// 是否启用签到奖励。
/// </summary>
public bool IsSigninRewardEnabled { get; init; }
/// <summary>
/// 签到奖励积分。
/// </summary>
public int SigninRewardPoints { get; init; }
/// <summary>
/// 有效期模式permanent/yearly_clear
/// </summary>
public string ExpiryMode { get; init; } = "yearly_clear";
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 核销积分商城兑换记录命令。
/// </summary>
public sealed class VerifyPointMallRecordCommand : IRequest<MemberPointMallRecordDetailDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 兑换记录标识。
/// </summary>
public long RecordId { get; init; }
/// <summary>
/// 核销方式scan/manual
/// </summary>
public string VerifyMethod { get; init; } = "manual";
/// <summary>
/// 核销备注。
/// </summary>
public string? VerifyRemark { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
/// <summary>
/// 写入积分商城兑换记录命令。
/// </summary>
public sealed class WritePointMallRecordCommand : IRequest<MemberPointMallRecordDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; init; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 兑换时间(可空,默认当前 UTC
/// </summary>
public DateTime? RedeemedAt { get; init; }
}

View File

@@ -0,0 +1,107 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换商品数据。
/// </summary>
public sealed class MemberPointMallProductDto
{
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; set; }
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 展示名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 展示图片。
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// 兑换类型编码product/coupon/physical
/// </summary>
public string RedeemType { get; set; } = "product";
/// <summary>
/// 关联商品 ID。
/// </summary>
public long? ProductId { get; set; }
/// <summary>
/// 关联优惠券模板 ID。
/// </summary>
public long? CouponTemplateId { get; set; }
/// <summary>
/// 实物名称。
/// </summary>
public string? PhysicalName { get; set; }
/// <summary>
/// 领取方式编码store_pickup/delivery
/// </summary>
public string? PickupMethod { get; set; }
/// <summary>
/// 商品描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 兑换方式编码points/mixed
/// </summary>
public string ExchangeType { get; set; } = "points";
/// <summary>
/// 所需积分。
/// </summary>
public int RequiredPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 初始库存。
/// </summary>
public int StockTotal { get; set; }
/// <summary>
/// 剩余库存。
/// </summary>
public int StockAvailable { get; set; }
/// <summary>
/// 已兑换数量。
/// </summary>
public int RedeemedCount { get; set; }
/// <summary>
/// 每人限兑次数。
/// </summary>
public int? PerMemberLimit { get; set; }
/// <summary>
/// 通知渠道列表in_app/sms
/// </summary>
public IReadOnlyList<string> NotifyChannels { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换商品列表结果。
/// </summary>
public sealed class MemberPointMallProductListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberPointMallProductDto> Items { get; set; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换记录详情数据。
/// </summary>
public sealed class MemberPointMallRecordDetailDto : MemberPointMallRecordDto
{
/// <summary>
/// 核销方式scan/manual
/// </summary>
public string? VerifyMethod { get; set; }
/// <summary>
/// 核销备注。
/// </summary>
public string? VerifyRemark { get; set; }
/// <summary>
/// 核销人标识。
/// </summary>
public long? VerifiedBy { get; set; }
}

View File

@@ -0,0 +1,82 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换记录数据。
/// </summary>
public class MemberPointMallRecordDto
{
/// <summary>
/// 兑换记录标识。
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 兑换单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; set; }
/// <summary>
/// 商品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string RedeemType { get; set; } = "product";
/// <summary>
/// 兑换方式points/mixed
/// </summary>
public string ExchangeType { get; set; } = "points";
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberMobileMasked { get; set; } = string.Empty;
/// <summary>
/// 消耗积分。
/// </summary>
public int UsedPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 记录状态pending_pickup/issued/completed/canceled
/// </summary>
public string Status { get; set; } = "issued";
/// <summary>
/// 兑换时间UTC
/// </summary>
public DateTime RedeemedAt { get; set; }
/// <summary>
/// 发放时间UTC
/// </summary>
public DateTime? IssuedAt { get; set; }
/// <summary>
/// 核销时间UTC
/// </summary>
public DateTime? VerifiedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换记录导出结果。
/// </summary>
public sealed class MemberPointMallRecordExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 文件内容。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总条数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换记录列表结果。
/// </summary>
public sealed class MemberPointMallRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberPointMallRecordDto> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 页面统计。
/// </summary>
public MemberPointMallRecordStatsDto Stats { get; set; } = new();
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分商城兑换记录页统计。
/// </summary>
public sealed class MemberPointMallRecordStatsDto
{
/// <summary>
/// 今日兑换。
/// </summary>
public int TodayRedeemCount { get; set; }
/// <summary>
/// 待领取实物。
/// </summary>
public int PendingPhysicalCount { get; set; }
/// <summary>
/// 本月消耗积分。
/// </summary>
public int CurrentMonthUsedPoints { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分规则页详情结果。
/// </summary>
public sealed class MemberPointMallRuleDetailResultDto
{
/// <summary>
/// 规则配置。
/// </summary>
public MemberPointMallRuleDto Rule { get; set; } = new();
/// <summary>
/// 统计数据。
/// </summary>
public MemberPointMallRuleStatsDto Stats { get; set; } = new();
}

View File

@@ -0,0 +1,62 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分规则数据。
/// </summary>
public sealed class MemberPointMallRuleDto
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 是否启用消费获取。
/// </summary>
public bool IsConsumeRewardEnabled { get; set; }
/// <summary>
/// 每消费多少元触发一次积分计算。
/// </summary>
public int ConsumeAmountPerStep { get; set; }
/// <summary>
/// 每步获得积分。
/// </summary>
public int ConsumeRewardPointsPerStep { get; set; }
/// <summary>
/// 是否启用评价奖励。
/// </summary>
public bool IsReviewRewardEnabled { get; set; }
/// <summary>
/// 评价奖励积分。
/// </summary>
public int ReviewRewardPoints { get; set; }
/// <summary>
/// 是否启用注册奖励。
/// </summary>
public bool IsRegisterRewardEnabled { get; set; }
/// <summary>
/// 注册奖励积分。
/// </summary>
public int RegisterRewardPoints { get; set; }
/// <summary>
/// 是否启用签到奖励。
/// </summary>
public bool IsSigninRewardEnabled { get; set; }
/// <summary>
/// 签到奖励积分。
/// </summary>
public int SigninRewardPoints { get; set; }
/// <summary>
/// 有效期模式permanent/yearly_clear
/// </summary>
public string ExpiryMode { get; set; } = "yearly_clear";
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
/// <summary>
/// 积分规则页统计数据。
/// </summary>
public sealed class MemberPointMallRuleStatsDto
{
/// <summary>
/// 累计发放积分。
/// </summary>
public int TotalIssuedPoints { get; set; }
/// <summary>
/// 已兑换积分。
/// </summary>
public int RedeemedPoints { get; set; }
/// <summary>
/// 积分用户。
/// </summary>
public int PointMembers { get; set; }
/// <summary>
/// 兑换率0-100
/// </summary>
public decimal RedeemRate { get; set; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 修改积分商城商品状态处理器。
/// </summary>
public sealed class ChangePointMallProductStatusCommandHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangePointMallProductStatusCommand, MemberPointMallProductDto>
{
/// <inheritdoc />
public async Task<MemberPointMallProductDto> Handle(
ChangePointMallProductStatusCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var status = MemberPointMallMapping.ParseProductStatus(request.Status);
var product = await repository.FindProductByIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在");
product.Status = status;
await repository.UpdateProductAsync(product, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetProductAggregatesAsync(
tenantId,
request.StoreId,
[product.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(product.Id, out var value)
? value
: MemberPointMallDtoFactory.EmptyProductAggregate(product.Id);
return MemberPointMallDtoFactory.ToProductDto(product, aggregate);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 删除积分商城商品处理器。
/// </summary>
public sealed class DeletePointMallProductCommandHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<DeletePointMallProductCommand>
{
/// <inheritdoc />
public async Task Handle(DeletePointMallProductCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await repository.FindProductByIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在");
var hasRecords = await repository.HasRecordsByProductIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId,
cancellationToken);
if (hasRecords)
{
throw new BusinessException(ErrorCodes.BadRequest, "存在兑换记录的商品不允许删除");
}
await repository.DeleteProductAsync(product, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,83 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 导出积分商城兑换记录处理器。
/// </summary>
public sealed class ExportPointMallRecordCsvQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportPointMallRecordCsvQuery, MemberPointMallRecordExportDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRecordExportDto> Handle(
ExportPointMallRecordCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var redeemType = MemberPointMallMapping.TryParseRedeemType(request.RedeemType);
var status = MemberPointMallMapping.TryParseRecordStatus(request.Status);
var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword);
var (startUtc, endUtc) = MemberPointMallMapping.NormalizeDateRange(
request.StartDateUtc,
request.EndDateUtc);
var records = await repository.ListRecordsForExportAsync(
tenantId,
request.StoreId,
redeemType,
status,
startUtc,
endUtc,
keyword,
cancellationToken);
var csv = BuildCsv(records);
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
return new MemberPointMallRecordExportDto
{
FileName = $"积分商城兑换记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = records.Count
};
}
private static string BuildCsv(IReadOnlyCollection<MemberPointMallRecord> records)
{
var lines = new List<string>
{
"兑换单号,会员,手机号,兑换商品,类型,消耗积分,现金部分,兑换时间,状态,核销时间"
};
foreach (var item in records)
{
lines.Add(string.Join(",",
Escape(item.RecordNo),
Escape(item.MemberName),
Escape(item.MemberMobileMasked),
Escape(item.ProductName),
Escape(MemberPointMallMapping.ToRedeemTypeDisplayText(item.RedeemType)),
item.UsedPoints.ToString(),
item.CashAmount.ToString("0.00"),
Escape(item.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss")),
Escape(MemberPointMallMapping.ToRecordStatusDisplayText(item.Status)),
Escape(item.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? string.Empty)));
}
return string.Join('\n', lines);
}
private static string Escape(string value)
{
var normalized = value.Replace("\"", "\"\"");
return $"\"{normalized}\"";
}
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 查询积分商城商品详情处理器。
/// </summary>
public sealed class GetPointMallProductDetailQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPointMallProductDetailQuery, MemberPointMallProductDto>
{
/// <inheritdoc />
public async Task<MemberPointMallProductDto> Handle(
GetPointMallProductDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var product = await repository.GetProductByIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在");
var aggregates = await repository.GetProductAggregatesAsync(
tenantId,
request.StoreId,
[product.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(product.Id, out var value)
? value
: MemberPointMallDtoFactory.EmptyProductAggregate(product.Id);
return MemberPointMallDtoFactory.ToProductDto(product, aggregate);
}
}

View File

@@ -0,0 +1,55 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 查询积分商城商品列表处理器。
/// </summary>
public sealed class GetPointMallProductListQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPointMallProductListQuery, MemberPointMallProductListResultDto>
{
/// <inheritdoc />
public async Task<MemberPointMallProductListResultDto> Handle(
GetPointMallProductListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var status = MemberPointMallMapping.TryParseProductStatus(request.Status);
var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword);
var items = await repository.SearchProductsAsync(
tenantId,
request.StoreId,
status,
keyword,
cancellationToken);
var productIds = items.Select(item => item.Id).ToList();
var aggregates = await repository.GetProductAggregatesAsync(
tenantId,
request.StoreId,
productIds,
cancellationToken);
var rows = items
.Select(item =>
{
var aggregate = aggregates.TryGetValue(item.Id, out var value)
? value
: MemberPointMallDtoFactory.EmptyProductAggregate(item.Id);
return MemberPointMallDtoFactory.ToProductDto(item, aggregate);
})
.ToList();
return new MemberPointMallProductListResultDto
{
Items = rows
};
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 查询积分商城兑换记录详情处理器。
/// </summary>
public sealed class GetPointMallRecordDetailQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPointMallRecordDetailQuery, MemberPointMallRecordDetailDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRecordDetailDto> Handle(
GetPointMallRecordDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var record = await repository.GetRecordByIdAsync(
tenantId,
request.StoreId,
request.RecordId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换记录不存在");
return MemberPointMallDtoFactory.ToRecordDetailDto(record);
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 查询积分商城兑换记录分页处理器。
/// </summary>
public sealed class GetPointMallRecordListQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPointMallRecordListQuery, MemberPointMallRecordListResultDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRecordListResultDto> Handle(
GetPointMallRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var redeemType = MemberPointMallMapping.TryParseRedeemType(request.RedeemType);
var status = MemberPointMallMapping.TryParseRecordStatus(request.Status);
var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword);
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var (startUtc, endUtc) = MemberPointMallMapping.NormalizeDateRange(
request.StartDateUtc,
request.EndDateUtc);
var (items, totalCount) = await repository.SearchRecordsAsync(
tenantId,
request.StoreId,
redeemType,
status,
startUtc,
endUtc,
keyword,
page,
pageSize,
cancellationToken);
var stats = await repository.GetRecordStatsAsync(
tenantId,
request.StoreId,
DateTime.UtcNow,
cancellationToken);
return new MemberPointMallRecordListResultDto
{
Items = items.Select(MemberPointMallDtoFactory.ToRecordDto).ToList(),
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = MemberPointMallDtoFactory.ToRecordStatsDto(stats)
};
}
}

View File

@@ -0,0 +1,53 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 查询积分商城规则详情处理器。
/// </summary>
public sealed class GetPointMallRuleDetailQueryHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPointMallRuleDetailQuery, MemberPointMallRuleDetailResultDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRuleDetailResultDto> Handle(
GetPointMallRuleDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rule = await repository.GetRuleByStoreAsync(
tenantId,
request.StoreId,
cancellationToken) ?? new MemberPointMallRule
{
StoreId = request.StoreId,
IsConsumeRewardEnabled = true,
ConsumeAmountPerStep = 1,
ConsumeRewardPointsPerStep = 1,
IsReviewRewardEnabled = true,
ReviewRewardPoints = 10,
IsRegisterRewardEnabled = true,
RegisterRewardPoints = 100,
IsSigninRewardEnabled = false,
SigninRewardPoints = 5
};
var stats = await repository.GetRuleStatsAsync(
tenantId,
request.StoreId,
cancellationToken);
return new MemberPointMallRuleDetailResultDto
{
Rule = MemberPointMallDtoFactory.ToRuleDto(rule),
Stats = MemberPointMallDtoFactory.ToRuleStatsDto(stats)
};
}
}

View File

@@ -0,0 +1,147 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 保存积分商城商品处理器。
/// </summary>
public sealed class SavePointMallProductCommandHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SavePointMallProductCommand, MemberPointMallProductDto>
{
/// <inheritdoc />
public async Task<MemberPointMallProductDto> Handle(
SavePointMallProductCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var redeemType = MemberPointMallMapping.ParseRedeemType(request.RedeemType);
var exchangeType = MemberPointMallMapping.ParseExchangeType(request.ExchangeType);
var status = MemberPointMallMapping.ParseProductStatus(request.Status);
var name = MemberPointMallMapping.NormalizeName(request.Name);
var imageUrl = MemberPointMallMapping.NormalizeImageUrl(request.ImageUrl);
var description = MemberPointMallMapping.NormalizeDescription(request.Description);
var requiredPoints = MemberPointMallMapping.NormalizeRequiredPoints(request.RequiredPoints);
var cashAmount = MemberPointMallMapping.NormalizeCashAmount(request.CashAmount, exchangeType);
var stockTotal = MemberPointMallMapping.NormalizeStockTotal(request.StockTotal);
var perMemberLimit = MemberPointMallMapping.NormalizePerMemberLimit(request.PerMemberLimit);
var notifyChannels = MemberPointMallMapping.ParseNotifyChannels(request.NotifyChannels);
var productId = (long?)null;
var couponTemplateId = (long?)null;
var physicalName = (string?)null;
MemberPointMallPickupMethod? pickupMethod = null;
switch (redeemType)
{
case MemberPointMallRedeemType.Product:
{
productId = request.ProductId.HasValue && request.ProductId.Value > 0
? request.ProductId.Value
: throw new BusinessException(ErrorCodes.BadRequest, "兑换商品类型必须选择关联商品");
break;
}
case MemberPointMallRedeemType.Coupon:
{
couponTemplateId = request.CouponTemplateId.HasValue && request.CouponTemplateId.Value > 0
? request.CouponTemplateId.Value
: throw new BusinessException(ErrorCodes.BadRequest, "兑换优惠券类型必须选择关联优惠券");
break;
}
case MemberPointMallRedeemType.Physical:
{
physicalName = MemberPointMallMapping.NormalizePhysicalName(request.PhysicalName);
pickupMethod = MemberPointMallMapping.ParsePickupMethod(request.PickupMethod);
break;
}
default:
{
throw new BusinessException(ErrorCodes.BadRequest, "redeemType 参数不合法");
}
}
MemberPointMallProduct entity;
if (request.PointMallProductId.HasValue && request.PointMallProductId.Value > 0)
{
entity = await repository.FindProductByIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在");
var redeemedCount = Math.Max(0, entity.StockTotal - entity.StockAvailable);
if (stockTotal < redeemedCount)
{
throw new BusinessException(ErrorCodes.BadRequest, "库存总量不能小于已兑换数量");
}
entity.Name = name;
entity.ImageUrl = imageUrl;
entity.RedeemType = redeemType;
entity.ProductId = productId;
entity.CouponTemplateId = couponTemplateId;
entity.PhysicalName = physicalName;
entity.PickupMethod = pickupMethod;
entity.Description = description;
entity.ExchangeType = exchangeType;
entity.RequiredPoints = requiredPoints;
entity.CashAmount = cashAmount;
entity.StockTotal = stockTotal;
entity.StockAvailable = stockTotal - redeemedCount;
entity.PerMemberLimit = perMemberLimit;
entity.NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels);
entity.Status = status;
await repository.UpdateProductAsync(entity, cancellationToken);
}
else
{
entity = new MemberPointMallProduct
{
StoreId = request.StoreId,
Name = name,
ImageUrl = imageUrl,
RedeemType = redeemType,
ProductId = productId,
CouponTemplateId = couponTemplateId,
PhysicalName = physicalName,
PickupMethod = pickupMethod,
Description = description,
ExchangeType = exchangeType,
RequiredPoints = requiredPoints,
CashAmount = cashAmount,
StockTotal = stockTotal,
StockAvailable = stockTotal,
PerMemberLimit = perMemberLimit,
NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels),
Status = status
};
await repository.AddProductAsync(entity, cancellationToken);
}
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetProductAggregatesAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(entity.Id, out var value)
? value
: MemberPointMallDtoFactory.EmptyProductAggregate(entity.Id);
return MemberPointMallDtoFactory.ToProductDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,75 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 保存积分商城规则处理器。
/// </summary>
public sealed class SavePointMallRuleCommandHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SavePointMallRuleCommand, MemberPointMallRuleDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRuleDto> Handle(
SavePointMallRuleCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var expiryMode = MemberPointMallMapping.ParseExpiryMode(request.ExpiryMode);
var consumeAmountPerStep = request.IsConsumeRewardEnabled
? MemberPointMallMapping.NormalizePositiveInt(request.ConsumeAmountPerStep, "consumeAmountPerStep")
: Math.Max(1, request.ConsumeAmountPerStep);
var consumeRewardPointsPerStep = request.IsConsumeRewardEnabled
? MemberPointMallMapping.NormalizePositiveInt(request.ConsumeRewardPointsPerStep, "consumeRewardPointsPerStep")
: Math.Max(0, request.ConsumeRewardPointsPerStep);
var reviewRewardPoints = request.IsReviewRewardEnabled
? MemberPointMallMapping.NormalizePositiveInt(request.ReviewRewardPoints, "reviewRewardPoints")
: Math.Max(0, request.ReviewRewardPoints);
var registerRewardPoints = request.IsRegisterRewardEnabled
? MemberPointMallMapping.NormalizePositiveInt(request.RegisterRewardPoints, "registerRewardPoints")
: Math.Max(0, request.RegisterRewardPoints);
var signinRewardPoints = request.IsSigninRewardEnabled
? MemberPointMallMapping.NormalizePositiveInt(request.SigninRewardPoints, "signinRewardPoints")
: Math.Max(0, request.SigninRewardPoints);
var existing = await repository.GetRuleByStoreAsync(
tenantId,
request.StoreId,
cancellationToken);
if (existing is null)
{
var created = MemberPointMallDtoFactory.CreateRuleEntity(request, expiryMode);
created.ConsumeAmountPerStep = consumeAmountPerStep;
created.ConsumeRewardPointsPerStep = consumeRewardPointsPerStep;
created.ReviewRewardPoints = reviewRewardPoints;
created.RegisterRewardPoints = registerRewardPoints;
created.SigninRewardPoints = signinRewardPoints;
await repository.AddRuleAsync(created, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return MemberPointMallDtoFactory.ToRuleDto(created);
}
existing.IsConsumeRewardEnabled = request.IsConsumeRewardEnabled;
existing.ConsumeAmountPerStep = consumeAmountPerStep;
existing.ConsumeRewardPointsPerStep = consumeRewardPointsPerStep;
existing.IsReviewRewardEnabled = request.IsReviewRewardEnabled;
existing.ReviewRewardPoints = reviewRewardPoints;
existing.IsRegisterRewardEnabled = request.IsRegisterRewardEnabled;
existing.RegisterRewardPoints = registerRewardPoints;
existing.IsSigninRewardEnabled = request.IsSigninRewardEnabled;
existing.SigninRewardPoints = signinRewardPoints;
existing.ExpiryMode = expiryMode;
await repository.UpdateRuleAsync(existing, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return MemberPointMallDtoFactory.ToRuleDto(existing);
}
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 核销积分商城兑换记录处理器。
/// </summary>
public sealed class VerifyPointMallRecordCommandHandler(
IPointMallRepository repository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<VerifyPointMallRecordCommand, MemberPointMallRecordDetailDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRecordDetailDto> Handle(
VerifyPointMallRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var verifyMethod = MemberPointMallMapping.ParseVerifyMethod(request.VerifyMethod);
var verifyRemark = MemberPointMallMapping.NormalizeRemark(request.VerifyRemark, "verifyRemark");
var record = await repository.FindRecordByIdAsync(
tenantId,
request.StoreId,
request.RecordId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换记录不存在");
if (record.Status != MemberPointMallRecordStatus.PendingPickup)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前状态不可核销");
}
var nowUtc = DateTime.UtcNow;
record.Status = MemberPointMallRecordStatus.Completed;
record.IssuedAt ??= nowUtc;
record.VerifiedAt = nowUtc;
record.VerifyMethod = verifyMethod;
record.VerifyRemark = verifyRemark;
record.VerifiedBy = currentUserAccessor.IsAuthenticated && currentUserAccessor.UserId > 0
? currentUserAccessor.UserId
: null;
await repository.UpdateRecordAsync(record, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return MemberPointMallDtoFactory.ToRecordDetailDto(record);
}
}

View File

@@ -0,0 +1,118 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers;
/// <summary>
/// 写入积分商城兑换记录处理器。
/// </summary>
public sealed class WritePointMallRecordCommandHandler(
IPointMallRepository repository,
IMemberRepository memberRepository,
ITenantProvider tenantProvider)
: IRequestHandler<WritePointMallRecordCommand, MemberPointMallRecordDto>
{
/// <inheritdoc />
public async Task<MemberPointMallRecordDto> Handle(
WritePointMallRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var redeemedAt = request.RedeemedAt.HasValue
? MemberPointMallMapping.NormalizeUtc(request.RedeemedAt.Value)
: DateTime.UtcNow;
var product = await repository.FindProductByIdAsync(
tenantId,
request.StoreId,
request.PointMallProductId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在");
if (product.Status != MemberPointMallProductStatus.Enabled)
{
throw new BusinessException(ErrorCodes.BadRequest, "兑换商品未上架");
}
if (product.StockAvailable <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "兑换商品库存不足");
}
var member = await memberRepository.FindProfileByIdAsync(
tenantId,
request.MemberId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
var usedPoints = MemberPointMallMapping.NormalizeRequiredPoints(product.RequiredPoints);
if (member.PointsBalance < usedPoints)
{
throw new BusinessException(ErrorCodes.BadRequest, "会员积分不足");
}
if (product.PerMemberLimit.HasValue && product.PerMemberLimit.Value > 0)
{
var redeemedCount = await repository.CountMemberRedeemsByProductAsync(
tenantId,
request.StoreId,
product.Id,
member.Id,
cancellationToken);
if (redeemedCount >= product.PerMemberLimit.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "已达到每人限兑次数");
}
}
member.PointsBalance -= usedPoints;
await memberRepository.UpdateProfileAsync(member, cancellationToken);
product.StockAvailable -= 1;
await repository.UpdateProductAsync(product, cancellationToken);
var initialStatus = MemberPointMallMapping.ResolveRecordInitialStatus(product.RedeemType);
var record = new MemberPointMallRecord
{
StoreId = request.StoreId,
RecordNo = MemberPointMallMapping.BuildRecordNo(redeemedAt),
PointMallProductId = product.Id,
MemberId = member.Id,
MemberName = MemberPointMallMapping.ResolveMemberName(member),
MemberMobileMasked = MemberPointMallMapping.ResolveMemberMobileMasked(member),
ProductName = product.Name,
RedeemType = product.RedeemType,
ExchangeType = product.ExchangeType,
UsedPoints = usedPoints,
CashAmount = product.CashAmount,
Status = initialStatus,
RedeemedAt = redeemedAt,
IssuedAt = MemberPointMallMapping.ResolveRecordInitialIssuedAt(product.RedeemType, redeemedAt)
};
await repository.AddRecordAsync(record, cancellationToken);
var ledger = new MemberPointLedger
{
MemberId = member.Id,
ChangeAmount = -usedPoints,
BalanceAfterChange = member.PointsBalance,
Reason = PointChangeReason.Redeem,
SourceId = product.Id,
OccurredAt = redeemedAt
};
await repository.AddPointLedgerAsync(ledger, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return MemberPointMallDtoFactory.ToRecordDto(record);
}
}

View File

@@ -0,0 +1,203 @@
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
namespace TakeoutSaaS.Application.App.Members.PointsMall;
/// <summary>
/// 积分商城 DTO 构造器。
/// </summary>
internal static class MemberPointMallDtoFactory
{
public static MemberPointMallProductAggregateSnapshot EmptyProductAggregate(long pointMallProductId)
{
return new MemberPointMallProductAggregateSnapshot
{
PointMallProductId = pointMallProductId,
RedeemedCount = 0
};
}
public static MemberPointMallRuleDto ToRuleDto(MemberPointMallRule source)
{
return new MemberPointMallRuleDto
{
StoreId = source.StoreId,
IsConsumeRewardEnabled = source.IsConsumeRewardEnabled,
ConsumeAmountPerStep = source.ConsumeAmountPerStep,
ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep,
IsReviewRewardEnabled = source.IsReviewRewardEnabled,
ReviewRewardPoints = source.ReviewRewardPoints,
IsRegisterRewardEnabled = source.IsRegisterRewardEnabled,
RegisterRewardPoints = source.RegisterRewardPoints,
IsSigninRewardEnabled = source.IsSigninRewardEnabled,
SigninRewardPoints = source.SigninRewardPoints,
ExpiryMode = MemberPointMallMapping.ToExpiryModeText(source.ExpiryMode)
};
}
public static MemberPointMallRuleStatsDto ToRuleStatsDto(MemberPointMallRuleStatsSnapshot source)
{
return new MemberPointMallRuleStatsDto
{
TotalIssuedPoints = source.TotalIssuedPoints,
RedeemedPoints = source.RedeemedPoints,
PointMembers = source.PointMembers,
RedeemRate = decimal.Round(source.RedeemRate, 1, MidpointRounding.AwayFromZero)
};
}
public static MemberPointMallProductDto ToProductDto(
MemberPointMallProduct source,
MemberPointMallProductAggregateSnapshot aggregate)
{
var notifyChannels = MemberPointMallMapping.DeserializeNotifyChannels(source.NotifyChannelsJson)
.Select(MemberPointMallMapping.ToNotifyChannelText)
.ToList();
return new MemberPointMallProductDto
{
PointMallProductId = source.Id,
StoreId = source.StoreId,
Name = source.Name,
ImageUrl = source.ImageUrl,
RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType),
ProductId = source.ProductId,
CouponTemplateId = source.CouponTemplateId,
PhysicalName = source.PhysicalName,
PickupMethod = source.PickupMethod.HasValue
? MemberPointMallMapping.ToPickupMethodText(source.PickupMethod.Value)
: null,
Description = source.Description,
ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType),
RequiredPoints = source.RequiredPoints,
CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero),
StockTotal = source.StockTotal,
StockAvailable = source.StockAvailable,
RedeemedCount = aggregate.RedeemedCount,
PerMemberLimit = source.PerMemberLimit,
NotifyChannels = notifyChannels,
Status = MemberPointMallMapping.ToProductStatusText(source.Status),
UpdatedAt = source.UpdatedAt ?? source.CreatedAt
};
}
public static MemberPointMallRecordDto ToRecordDto(MemberPointMallRecord source)
{
return new MemberPointMallRecordDto
{
RecordId = source.Id,
RecordNo = source.RecordNo,
PointMallProductId = source.PointMallProductId,
ProductName = source.ProductName,
RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType),
ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType),
MemberId = source.MemberId,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
UsedPoints = source.UsedPoints,
CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero),
Status = MemberPointMallMapping.ToRecordStatusText(source.Status),
RedeemedAt = source.RedeemedAt,
IssuedAt = source.IssuedAt,
VerifiedAt = source.VerifiedAt
};
}
public static MemberPointMallRecordDetailDto ToRecordDetailDto(MemberPointMallRecord source)
{
return new MemberPointMallRecordDetailDto
{
RecordId = source.Id,
RecordNo = source.RecordNo,
PointMallProductId = source.PointMallProductId,
ProductName = source.ProductName,
RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType),
ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType),
MemberId = source.MemberId,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
UsedPoints = source.UsedPoints,
CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero),
Status = MemberPointMallMapping.ToRecordStatusText(source.Status),
RedeemedAt = source.RedeemedAt,
IssuedAt = source.IssuedAt,
VerifiedAt = source.VerifiedAt,
VerifyMethod = source.VerifyMethod.HasValue
? MemberPointMallMapping.ToVerifyMethodText(source.VerifyMethod.Value)
: null,
VerifyRemark = source.VerifyRemark,
VerifiedBy = source.VerifiedBy
};
}
public static MemberPointMallRecordStatsDto ToRecordStatsDto(MemberPointMallRecordStatsSnapshot source)
{
return new MemberPointMallRecordStatsDto
{
TodayRedeemCount = source.TodayRedeemCount,
PendingPhysicalCount = source.PendingPhysicalCount,
CurrentMonthUsedPoints = source.CurrentMonthUsedPoints
};
}
public static MemberPointMallRule CreateRuleEntity(
SavePointMallRuleCommand request,
MemberPointMallExpiryMode expiryMode)
{
return new MemberPointMallRule
{
StoreId = request.StoreId,
IsConsumeRewardEnabled = request.IsConsumeRewardEnabled,
ConsumeAmountPerStep = request.ConsumeAmountPerStep,
ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep,
IsReviewRewardEnabled = request.IsReviewRewardEnabled,
ReviewRewardPoints = request.ReviewRewardPoints,
IsRegisterRewardEnabled = request.IsRegisterRewardEnabled,
RegisterRewardPoints = request.RegisterRewardPoints,
IsSigninRewardEnabled = request.IsSigninRewardEnabled,
SigninRewardPoints = request.SigninRewardPoints,
ExpiryMode = expiryMode
};
}
public static MemberPointMallProduct CreateProductEntity(
SavePointMallProductCommand request,
MemberPointMallRedeemType redeemType,
MemberPointMallExchangeType exchangeType,
MemberPointMallProductStatus status,
string name,
string? imageUrl,
string? physicalName,
MemberPointMallPickupMethod? pickupMethod,
string? description,
int requiredPoints,
decimal cashAmount,
int stockTotal,
int? perMemberLimit,
IReadOnlyCollection<MemberPointMallNotifyChannel> notifyChannels)
{
return new MemberPointMallProduct
{
StoreId = request.StoreId,
Name = name,
ImageUrl = imageUrl,
RedeemType = redeemType,
ProductId = request.ProductId,
CouponTemplateId = request.CouponTemplateId,
PhysicalName = physicalName,
PickupMethod = pickupMethod,
Description = description,
ExchangeType = exchangeType,
RequiredPoints = requiredPoints,
CashAmount = cashAmount,
StockTotal = stockTotal,
StockAvailable = stockTotal,
PerMemberLimit = perMemberLimit,
NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels),
Status = status
};
}
}

View File

@@ -0,0 +1,583 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.PointsMall;
/// <summary>
/// 积分商城模块映射与标准化工具。
/// </summary>
internal static class MemberPointMallMapping
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static MemberPointMallExpiryMode ParseExpiryMode(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"permanent" => MemberPointMallExpiryMode.Permanent,
"yearly_clear" => MemberPointMallExpiryMode.YearlyClear,
_ => throw new BusinessException(ErrorCodes.BadRequest, "expiryMode 参数不合法")
};
}
public static string ToExpiryModeText(MemberPointMallExpiryMode value)
{
return value switch
{
MemberPointMallExpiryMode.Permanent => "permanent",
MemberPointMallExpiryMode.YearlyClear => "yearly_clear",
_ => "yearly_clear"
};
}
public static MemberPointMallRedeemType ParseRedeemType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"product" => MemberPointMallRedeemType.Product,
"coupon" => MemberPointMallRedeemType.Coupon,
"physical" => MemberPointMallRedeemType.Physical,
_ => throw new BusinessException(ErrorCodes.BadRequest, "redeemType 参数不合法")
};
}
public static MemberPointMallRedeemType? TryParseRedeemType(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseRedeemType(value);
}
public static string ToRedeemTypeText(MemberPointMallRedeemType value)
{
return value switch
{
MemberPointMallRedeemType.Product => "product",
MemberPointMallRedeemType.Coupon => "coupon",
MemberPointMallRedeemType.Physical => "physical",
_ => "product"
};
}
public static string ToRedeemTypeDisplayText(MemberPointMallRedeemType value)
{
return value switch
{
MemberPointMallRedeemType.Product => "商品",
MemberPointMallRedeemType.Coupon => "优惠券",
MemberPointMallRedeemType.Physical => "实物",
_ => "未知"
};
}
public static MemberPointMallExchangeType ParseExchangeType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"points" => MemberPointMallExchangeType.PointsOnly,
"mixed" => MemberPointMallExchangeType.PointsAndCash,
_ => throw new BusinessException(ErrorCodes.BadRequest, "exchangeType 参数不合法")
};
}
public static string ToExchangeTypeText(MemberPointMallExchangeType value)
{
return value switch
{
MemberPointMallExchangeType.PointsOnly => "points",
MemberPointMallExchangeType.PointsAndCash => "mixed",
_ => "points"
};
}
public static MemberPointMallProductStatus ParseProductStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"enabled" => MemberPointMallProductStatus.Enabled,
"disabled" => MemberPointMallProductStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static MemberPointMallProductStatus? TryParseProductStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseProductStatus(value);
}
public static string ToProductStatusText(MemberPointMallProductStatus value)
{
return value switch
{
MemberPointMallProductStatus.Enabled => "enabled",
MemberPointMallProductStatus.Disabled => "disabled",
_ => "disabled"
};
}
public static MemberPointMallPickupMethod ParsePickupMethod(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"store_pickup" => MemberPointMallPickupMethod.StorePickup,
"delivery" => MemberPointMallPickupMethod.Delivery,
_ => throw new BusinessException(ErrorCodes.BadRequest, "pickupMethod 参数不合法")
};
}
public static string ToPickupMethodText(MemberPointMallPickupMethod value)
{
return value switch
{
MemberPointMallPickupMethod.StorePickup => "store_pickup",
MemberPointMallPickupMethod.Delivery => "delivery",
_ => "store_pickup"
};
}
public static MemberPointMallVerifyMethod ParseVerifyMethod(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"scan" => MemberPointMallVerifyMethod.Scan,
"manual" => MemberPointMallVerifyMethod.Manual,
_ => throw new BusinessException(ErrorCodes.BadRequest, "verifyMethod 参数不合法")
};
}
public static string ToVerifyMethodText(MemberPointMallVerifyMethod value)
{
return value switch
{
MemberPointMallVerifyMethod.Scan => "scan",
MemberPointMallVerifyMethod.Manual => "manual",
_ => "manual"
};
}
public static string ToVerifyMethodDisplayText(MemberPointMallVerifyMethod value)
{
return value switch
{
MemberPointMallVerifyMethod.Scan => "扫码核销",
MemberPointMallVerifyMethod.Manual => "手动核销",
_ => "未知"
};
}
public static MemberPointMallRecordStatus ParseRecordStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"pending_pickup" => MemberPointMallRecordStatus.PendingPickup,
"issued" => MemberPointMallRecordStatus.Issued,
"completed" => MemberPointMallRecordStatus.Completed,
"canceled" => MemberPointMallRecordStatus.Canceled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static MemberPointMallRecordStatus? TryParseRecordStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseRecordStatus(value);
}
public static string ToRecordStatusText(MemberPointMallRecordStatus value)
{
return value switch
{
MemberPointMallRecordStatus.PendingPickup => "pending_pickup",
MemberPointMallRecordStatus.Issued => "issued",
MemberPointMallRecordStatus.Completed => "completed",
MemberPointMallRecordStatus.Canceled => "canceled",
_ => "issued"
};
}
public static string ToRecordStatusDisplayText(MemberPointMallRecordStatus value)
{
return value switch
{
MemberPointMallRecordStatus.PendingPickup => "待领取",
MemberPointMallRecordStatus.Issued => "已发放",
MemberPointMallRecordStatus.Completed => "已完成",
MemberPointMallRecordStatus.Canceled => "已取消",
_ => "未知"
};
}
public static MemberPointMallNotifyChannel ParseNotifyChannel(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"in_app" => MemberPointMallNotifyChannel.InApp,
"sms" => MemberPointMallNotifyChannel.Sms,
_ => throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 参数不合法")
};
}
public static string ToNotifyChannelText(MemberPointMallNotifyChannel value)
{
return value switch
{
MemberPointMallNotifyChannel.InApp => "in_app",
MemberPointMallNotifyChannel.Sms => "sms",
_ => "in_app"
};
}
public static IReadOnlyList<MemberPointMallNotifyChannel> ParseNotifyChannels(
IReadOnlyCollection<string>? values)
{
var parsed = (values ?? Array.Empty<string>())
.Select(ParseNotifyChannel)
.Distinct()
.ToList();
if (parsed.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 至少选择一项");
}
return parsed;
}
public static IReadOnlyList<MemberPointMallNotifyChannel> DeserializeNotifyChannels(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
try
{
var source = JsonSerializer.Deserialize<List<string>>(value, JsonOptions) ?? [];
var channels = source
.Select(item =>
{
try
{
return (MemberPointMallNotifyChannel?)ParseNotifyChannel(item);
}
catch
{
return null;
}
})
.Where(item => item.HasValue)
.Select(item => item!.Value)
.Distinct()
.ToList();
return channels;
}
catch
{
return [];
}
}
public static string SerializeNotifyChannels(IReadOnlyCollection<MemberPointMallNotifyChannel> values)
{
var payload = (values ?? Array.Empty<MemberPointMallNotifyChannel>())
.Distinct()
.OrderBy(item => item)
.Select(ToNotifyChannelText)
.ToList();
return JsonSerializer.Serialize(payload, JsonOptions);
}
public static string NormalizeName(string? value, string fieldName = "name")
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64");
}
return normalized;
}
public static string? NormalizePhysicalName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "physicalName 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "physicalName 长度不能超过 64");
}
return normalized;
}
public static string? NormalizeImageUrl(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "imageUrl 长度不能超过 512");
}
return normalized;
}
public static string? NormalizeDescription(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
}
return normalized;
}
public static string? NormalizeKeyword(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "keyword 长度不能超过 64");
}
return normalized;
}
public static string? NormalizeRemark(string? value, string fieldName = "remark")
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 256)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 256");
}
return normalized;
}
public static int NormalizePositiveInt(int value, string fieldName)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0");
}
return value;
}
public static int NormalizeRequiredPoints(int value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "requiredPoints 必须大于 0");
}
if (value > 1_000_000)
{
throw new BusinessException(ErrorCodes.BadRequest, "requiredPoints 不能超过 1000000");
}
return value;
}
public static int NormalizeStockTotal(int value)
{
if (value < 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "stockTotal 不能小于 0");
}
if (value > 10_000_000)
{
throw new BusinessException(ErrorCodes.BadRequest, "stockTotal 不能超过 10000000");
}
return value;
}
public static int? NormalizePerMemberLimit(int? value)
{
if (!value.HasValue || value.Value <= 0)
{
return null;
}
if (value.Value > 9999)
{
throw new BusinessException(ErrorCodes.BadRequest, "perMemberLimit 不能超过 9999");
}
return value.Value;
}
public static decimal NormalizeCashAmount(decimal value, MemberPointMallExchangeType exchangeType)
{
var normalized = decimal.Round(value, 2, MidpointRounding.AwayFromZero);
if (exchangeType == MemberPointMallExchangeType.PointsOnly)
{
return 0m;
}
if (normalized <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "cashAmount 必须大于 0");
}
return normalized;
}
public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc)
{
DateTime? normalizedStart = null;
DateTime? normalizedEnd = null;
if (startUtc.HasValue)
{
var utc = NormalizeUtc(startUtc.Value);
normalizedStart = new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc);
}
if (endUtc.HasValue)
{
var utc = NormalizeUtc(endUtc.Value);
normalizedEnd = new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc)
.AddDays(1)
.AddTicks(-1);
}
if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart.Value > normalizedEnd.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
}
return (normalizedStart, normalizedEnd);
}
public static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
public static string ResolveMemberName(MemberProfile member)
{
var nickname = (member.Nickname ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(nickname))
{
return nickname.Length <= 64 ? nickname : nickname[..64];
}
var mobile = NormalizePhone(member.Mobile);
return mobile.Length >= 4 ? $"会员{mobile[^4..]}" : "会员";
}
public static string ResolveMemberMobileMasked(MemberProfile member)
{
return MaskPhone(NormalizePhone(member.Mobile));
}
public static string BuildRecordNo(DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
return $"PT{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
public static MemberPointMallRecordStatus ResolveRecordInitialStatus(MemberPointMallRedeemType redeemType)
{
return redeemType == MemberPointMallRedeemType.Physical
? MemberPointMallRecordStatus.PendingPickup
: MemberPointMallRecordStatus.Issued;
}
public static DateTime? ResolveRecordInitialIssuedAt(MemberPointMallRedeemType redeemType, DateTime redeemedAt)
{
return redeemType == MemberPointMallRedeemType.Physical ? null : redeemedAt;
}
private static string NormalizePhone(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
private static string MaskPhone(string normalizedPhone)
{
if (normalizedPhone.Length >= 11)
{
return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}";
}
if (normalizedPhone.Length >= 7)
{
return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}";
}
return normalizedPhone;
}
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 导出积分商城兑换记录 CSV。
/// </summary>
public sealed class ExportPointMallRecordCsvQuery : IRequest<MemberPointMallRecordExportDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string? RedeemType { get; init; }
/// <summary>
/// 状态pending_pickup/issued/completed/canceled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 开始日期UTC可空
/// </summary>
public DateTime? StartDateUtc { get; init; }
/// <summary>
/// 结束日期UTC可空
/// </summary>
public DateTime? EndDateUtc { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 查询积分商城商品详情。
/// </summary>
public sealed class GetPointMallProductDetailQuery : IRequest<MemberPointMallProductDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 积分商城商品标识。
/// </summary>
public long PointMallProductId { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 查询积分商城商品列表。
/// </summary>
public sealed class GetPointMallProductListQuery : IRequest<MemberPointMallProductListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 状态筛选enabled/disabled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 关键字(名称)。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 查询积分商城兑换记录详情。
/// </summary>
public sealed class GetPointMallRecordDetailQuery : IRequest<MemberPointMallRecordDetailDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 兑换记录标识。
/// </summary>
public long RecordId { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 查询积分商城兑换记录分页。
/// </summary>
public sealed class GetPointMallRecordListQuery : IRequest<MemberPointMallRecordListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string? RedeemType { get; init; }
/// <summary>
/// 状态pending_pickup/issued/completed/canceled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 开始日期UTC可空
/// </summary>
public DateTime? StartDateUtc { get; init; }
/// <summary>
/// 结束日期UTC可空
/// </summary>
public DateTime? EndDateUtc { get; init; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
/// <summary>
/// 查询积分商城规则详情。
/// </summary>
public sealed class GetPointMallRuleDetailQuery : IRequest<MemberPointMallRuleDetailResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
}