feat: implement tenant member stored card module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m24s

This commit is contained in:
2026-03-04 09:14:57 +08:00
parent d96ca4971a
commit 2970134200
35 changed files with 12805 additions and 1 deletions

View File

@@ -0,0 +1,399 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 储值卡方案列表请求。
/// </summary>
public sealed class StoredCardPlanListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 保存储值卡方案请求。
/// </summary>
public sealed class SaveStoredCardPlanRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID编辑时传
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; } = 100;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 修改方案状态请求。
/// </summary>
public sealed class ChangeStoredCardPlanStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "disabled";
}
/// <summary>
/// 删除方案请求。
/// </summary>
public sealed class DeleteStoredCardPlanRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
}
/// <summary>
/// 充值记录分页查询请求。
/// </summary>
public sealed class StoredCardRechargeRecordListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 关键字(会员名称/手机号/单号)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 8;
}
/// <summary>
/// 充值记录导出请求。
/// </summary>
public sealed class ExportStoredCardRechargeRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 关键字(会员名称/手机号/单号)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 写入充值记录请求。
/// </summary>
public sealed class WriteStoredCardRechargeRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 会员 ID必填
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID可空
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance
/// </summary>
public string PaymentMethod { get; set; } = "wechat";
/// <summary>
/// 充值时间(可空,默认当前时间)。
/// </summary>
public DateTime? RechargedAt { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 储值卡方案统计响应。
/// </summary>
public sealed class StoredCardPlanStatsResponse
{
/// <summary>
/// 储值总额。
/// </summary>
public decimal TotalRechargeAmount { get; set; }
/// <summary>
/// 赠金总额。
/// </summary>
public decimal TotalGiftAmount { get; set; }
/// <summary>
/// 本月充值。
/// </summary>
public decimal CurrentMonthRechargeAmount { get; set; }
/// <summary>
/// 储值用户。
/// </summary>
public int RechargeMemberCount { get; set; }
}
/// <summary>
/// 储值卡方案响应。
/// </summary>
public sealed class StoredCardPlanResponse
{
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 状态。
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 累计充值次数。
/// </summary>
public int RechargeCount { get; set; }
/// <summary>
/// 累计充值金额。
/// </summary>
public decimal TotalRechargeAmount { get; set; }
}
/// <summary>
/// 储值卡方案列表响应。
/// </summary>
public sealed class StoredCardPlanListResultResponse
{
/// <summary>
/// 方案列表。
/// </summary>
public List<StoredCardPlanResponse> Items { get; set; } = [];
/// <summary>
/// 页面统计。
/// </summary>
public StoredCardPlanStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 充值记录响应。
/// </summary>
public sealed class StoredCardRechargeRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 充值单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 会员 ID。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MemberMobileMasked { get; set; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
/// <summary>
/// 支付方式编码。
/// </summary>
public string PaymentMethod { get; set; } = "unknown";
/// <summary>
/// 支付方式文案。
/// </summary>
public string PaymentMethodText { get; set; } = "未知";
/// <summary>
/// 充值时间(本地显示字符串)。
/// </summary>
public string RechargedAt { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 充值记录分页结果响应。
/// </summary>
public sealed class StoredCardRechargeRecordListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<StoredCardRechargeRecordResponse> 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 sealed class StoredCardRechargeRecordExportResponse
{
/// <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,283 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Member;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 会员中心储值卡管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/member/stored-card")]
public sealed class MemberStoredCardController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:member:stored-card:view";
private const string ManagePermission = "tenant:member:stored-card:manage";
/// <summary>
/// 获取储值卡方案列表。
/// </summary>
[HttpGet("plan/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanListResultResponse>> PlanList(
[FromQuery] StoredCardPlanListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetStoredCardPlanListQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<StoredCardPlanListResultResponse>.Ok(new StoredCardPlanListResultResponse
{
Items = result.Items.Select(MapPlan).ToList(),
Stats = new StoredCardPlanStatsResponse
{
TotalRechargeAmount = result.Stats.TotalRechargeAmount,
TotalGiftAmount = result.Stats.TotalGiftAmount,
CurrentMonthRechargeAmount = result.Stats.CurrentMonthRechargeAmount,
RechargeMemberCount = result.Stats.RechargeMemberCount
}
});
}
/// <summary>
/// 保存储值卡方案。
/// </summary>
[HttpPost("plan/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanResponse>> SavePlan(
[FromBody] SaveStoredCardPlanRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SaveStoredCardPlanCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
RechargeAmount = request.RechargeAmount,
GiftAmount = request.GiftAmount,
SortOrder = request.SortOrder,
Status = request.Status
}, cancellationToken);
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
}
/// <summary>
/// 修改储值卡方案状态。
/// </summary>
[HttpPost("plan/status")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanResponse>> ChangePlanStatus(
[FromBody] ChangeStoredCardPlanStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangeStoredCardPlanStatusCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
}
/// <summary>
/// 删除储值卡方案。
/// </summary>
[HttpPost("plan/delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeletePlan(
[FromBody] DeleteStoredCardPlanRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeleteStoredCardPlanCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取充值记录分页。
/// </summary>
[HttpGet("record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordListResultResponse>> RecordList(
[FromQuery] StoredCardRechargeRecordListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetStoredCardRechargeRecordListQuery
{
StoreId = storeId,
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<StoredCardRechargeRecordListResultResponse>.Ok(new StoredCardRechargeRecordListResultResponse
{
Items = result.Items.Select(MapRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 导出充值记录 CSV。
/// </summary>
[HttpGet("record/export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordExportResponse>> ExportRecord(
[FromQuery] ExportStoredCardRechargeRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ExportStoredCardRechargeRecordCsvQuery
{
StoreId = storeId,
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<StoredCardRechargeRecordExportResponse>.Ok(new StoredCardRechargeRecordExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 写入充值记录。
/// </summary>
[HttpPost("record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordResponse>> WriteRecord(
[FromBody] WriteStoredCardRechargeRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new WriteStoredCardRechargeRecordCommand
{
StoreId = storeId,
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
RechargeAmount = request.RechargeAmount,
GiftAmount = request.GiftAmount,
PaymentMethod = request.PaymentMethod,
RechargedAt = request.RechargedAt,
Remark = request.Remark
}, cancellationToken);
return ApiResponse<StoredCardRechargeRecordResponse>.Ok(MapRecord(result));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime? ParseDateOrNull(string? value, string fieldName)
{
return string.IsNullOrWhiteSpace(value)
? null
: StoreApiHelpers.ParseDateOnly(value, fieldName);
}
private static StoredCardPlanResponse MapPlan(MemberStoredCardPlanDto source)
{
return new StoredCardPlanResponse
{
PlanId = source.PlanId.ToString(),
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
SortOrder = source.SortOrder,
Status = source.Status,
RechargeCount = source.RechargeCount,
TotalRechargeAmount = source.TotalRechargeAmount
};
}
private static StoredCardRechargeRecordResponse MapRecord(MemberStoredCardRechargeRecordDto source)
{
return new StoredCardRechargeRecordResponse
{
RecordId = source.RecordId.ToString(),
RecordNo = source.RecordNo,
MemberId = source.MemberId.ToString(),
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
PaymentMethod = source.PaymentMethod,
PaymentMethodText = ResolvePaymentMethodText(source.PaymentMethod),
RechargedAt = source.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
PlanId = source.PlanId?.ToString(),
Remark = source.Remark
};
}
private static string ResolvePaymentMethodText(string paymentMethod)
{
return (paymentMethod ?? string.Empty).Trim().ToLowerInvariant() switch
{
"wechat" => "微信支付",
"alipay" => "支付宝",
"cash" => "现金",
"card" => "刷卡",
"balance" => "余额",
_ => "未知"
};
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 修改储值卡方案状态命令。
/// </summary>
public sealed class ChangeStoredCardPlanStatusCommand : IRequest<MemberStoredCardPlanDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { 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.StoredCard.Commands;
/// <summary>
/// 删除储值卡方案命令。
/// </summary>
public sealed class DeleteStoredCardPlanCommand : IRequest
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 保存储值卡方案命令。
/// </summary>
public sealed class SaveStoredCardPlanCommand : IRequest<MemberStoredCardPlanDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识(编辑时传)。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; } = 100;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 写入储值卡充值记录命令。
/// </summary>
public sealed class WriteStoredCardRechargeRecordCommand : IRequest<MemberStoredCardRechargeRecordDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 方案标识(可空)。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance
/// </summary>
public string PaymentMethod { get; init; } = "wechat";
/// <summary>
/// 充值时间(可空,默认当前时间)。
/// </summary>
public DateTime? RechargedAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案 DTO。
/// </summary>
public sealed class MemberStoredCardPlanDto
{
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 到账金额(充值+赠送)。
/// </summary>
public decimal ArrivedAmount { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 累计充值次数。
/// </summary>
public int RechargeCount { get; init; }
/// <summary>
/// 累计充值金额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案列表结果 DTO。
/// </summary>
public sealed class MemberStoredCardPlanListResultDto
{
/// <summary>
/// 方案列表。
/// </summary>
public IReadOnlyList<MemberStoredCardPlanDto> Items { get; init; } = [];
/// <summary>
/// 页面统计。
/// </summary>
public MemberStoredCardPlanStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案页统计 DTO。
/// </summary>
public sealed class MemberStoredCardPlanStatsDto
{
/// <summary>
/// 储值总额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
/// <summary>
/// 赠金总额。
/// </summary>
public decimal TotalGiftAmount { get; init; }
/// <summary>
/// 本月充值。
/// </summary>
public decimal CurrentMonthRechargeAmount { get; init; }
/// <summary>
/// 储值用户数。
/// </summary>
public int RechargeMemberCount { get; init; }
}

View File

@@ -0,0 +1,67 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordDto
{
/// <summary>
/// 记录标识。
/// </summary>
public long RecordId { get; init; }
/// <summary>
/// 充值单号。
/// </summary>
public string RecordNo { get; init; } = string.Empty;
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MemberMobileMasked { get; init; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; init; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance/unknown
/// </summary>
public string PaymentMethod { get; init; } = "unknown";
/// <summary>
/// 充值时间UTC
/// </summary>
public DateTime RechargedAt { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录导出 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录列表结果 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberStoredCardRechargeRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.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.StoredCard.Handlers;
/// <summary>
/// 修改储值卡方案状态处理器。
/// </summary>
public sealed class ChangeStoredCardPlanStatusCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeStoredCardPlanStatusCommand, MemberStoredCardPlanDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanDto> Handle(
ChangeStoredCardPlanStatusCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var status = MemberStoredCardMapping.ParsePlanStatus(request.Status);
var entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
entity.Status = status;
await repository.UpdatePlanAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(entity.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(entity.Id);
return MemberStoredCardDtoFactory.ToPlanDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.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.StoredCard.Handlers;
/// <summary>
/// 删除储值卡方案处理器。
/// </summary>
public sealed class DeleteStoredCardPlanCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteStoredCardPlanCommand>
{
/// <inheritdoc />
public async Task Handle(DeleteStoredCardPlanCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
await repository.DeletePlanAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,74 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 导出储值卡充值记录处理器。
/// </summary>
public sealed class ExportStoredCardRechargeRecordCsvQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportStoredCardRechargeRecordCsvQuery, MemberStoredCardRechargeRecordExportDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordExportDto> Handle(
ExportStoredCardRechargeRecordCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedKeyword = MemberStoredCardMapping.NormalizeKeyword(request.Keyword);
var (startUtc, endUtc) = MemberStoredCardMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
var records = await repository.ListRechargeRecordsForExportAsync(
tenantId,
request.StoreId,
startUtc,
endUtc,
normalizedKeyword,
cancellationToken);
var csv = BuildCsv(records);
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
return new MemberStoredCardRechargeRecordExportDto
{
FileName = $"储值卡充值记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = records.Count
};
}
private static string BuildCsv(IReadOnlyCollection<Domain.Membership.Entities.MemberStoredCardRechargeRecord> records)
{
var lines = new List<string>
{
"充值单号,会员,手机号,充值金额,赠送金额,到账金额,支付方式,充值时间"
};
foreach (var item in records)
{
lines.Add(string.Join(",",
Escape(item.RecordNo),
Escape(item.MemberName),
Escape(item.MemberMobileMasked),
item.RechargeAmount.ToString("0.00"),
item.GiftAmount.ToString("0.00"),
item.ArrivedAmount.ToString("0.00"),
Escape(MemberStoredCardMapping.ToPaymentMethodDisplayText(item.PaymentMethod)),
Escape(item.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss"))));
}
return string.Join('\n', lines);
}
private static string Escape(string value)
{
var text = value.Replace("\"", "\"\"");
return $"\"{text}\"";
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 储值卡方案列表查询处理器。
/// </summary>
public sealed class GetStoredCardPlanListQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetStoredCardPlanListQuery, MemberStoredCardPlanListResultDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanListResultDto> Handle(
GetStoredCardPlanListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var plans = await repository.GetPlansByStoreAsync(
tenantId,
request.StoreId,
cancellationToken);
var planIds = plans.Select(item => item.Id).ToList();
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
planIds,
cancellationToken);
var items = plans
.Select(item =>
{
var aggregate = aggregates.TryGetValue(item.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(item.Id);
return MemberStoredCardDtoFactory.ToPlanDto(item, aggregate);
})
.ToList();
var stats = await repository.GetPlanStatsAsync(
tenantId,
request.StoreId,
DateTime.UtcNow,
cancellationToken);
return new MemberStoredCardPlanListResultDto
{
Items = items,
Stats = MemberStoredCardDtoFactory.ToPlanStatsDto(stats)
};
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 储值卡充值记录列表查询处理器。
/// </summary>
public sealed class GetStoredCardRechargeRecordListQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetStoredCardRechargeRecordListQuery, MemberStoredCardRechargeRecordListResultDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordListResultDto> Handle(
GetStoredCardRechargeRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var normalizedKeyword = MemberStoredCardMapping.NormalizeKeyword(request.Keyword);
var (startUtc, endUtc) = MemberStoredCardMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
var (items, totalCount) = await repository.SearchRechargeRecordsAsync(
tenantId,
request.StoreId,
startUtc,
endUtc,
normalizedKeyword,
page,
pageSize,
cancellationToken);
return new MemberStoredCardRechargeRecordListResultDto
{
Items = items.Select(MemberStoredCardDtoFactory.ToRechargeRecordDto).ToList(),
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.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.StoredCard.Handlers;
/// <summary>
/// 保存储值卡方案处理器。
/// </summary>
public sealed class SaveStoredCardPlanCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveStoredCardPlanCommand, MemberStoredCardPlanDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanDto> Handle(
SaveStoredCardPlanCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedRechargeAmount = MemberStoredCardMapping.NormalizeRechargeAmount(request.RechargeAmount);
var normalizedGiftAmount = MemberStoredCardMapping.NormalizeGiftAmount(request.GiftAmount);
var normalizedSortOrder = MemberStoredCardMapping.NormalizeSortOrder(request.SortOrder);
var status = MemberStoredCardMapping.ParsePlanStatus(request.Status);
var allPlans = await repository.GetPlansByStoreAsync(
tenantId,
request.StoreId,
cancellationToken);
var duplicated = allPlans.Any(item =>
item.Id != request.PlanId &&
item.RechargeAmount == normalizedRechargeAmount &&
item.GiftAmount == normalizedGiftAmount);
if (duplicated)
{
throw new BusinessException(ErrorCodes.BadRequest, "已存在相同充值金额与赠送金额的方案");
}
Domain.Membership.Entities.MemberStoredCardPlan entity;
if (request.PlanId.HasValue && request.PlanId.Value > 0)
{
entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
entity.RechargeAmount = normalizedRechargeAmount;
entity.GiftAmount = normalizedGiftAmount;
entity.SortOrder = normalizedSortOrder;
entity.Status = status;
await repository.UpdatePlanAsync(entity, cancellationToken);
}
else
{
entity = MemberStoredCardDtoFactory.CreatePlanEntity(
request,
normalizedRechargeAmount,
normalizedGiftAmount,
normalizedSortOrder,
status);
await repository.AddPlanAsync(entity, cancellationToken);
}
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(entity.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(entity.Id);
return MemberStoredCardDtoFactory.ToPlanDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,90 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.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.StoredCard.Handlers;
/// <summary>
/// 写入储值卡充值记录处理器。
/// </summary>
public sealed class WriteStoredCardRechargeRecordCommandHandler(
IStoredCardRepository storedCardRepository,
IMemberRepository memberRepository,
ITenantProvider tenantProvider)
: IRequestHandler<WriteStoredCardRechargeRecordCommand, MemberStoredCardRechargeRecordDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordDto> Handle(
WriteStoredCardRechargeRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rechargeAmount = MemberStoredCardMapping.NormalizeRechargeAmount(request.RechargeAmount);
var giftAmount = MemberStoredCardMapping.NormalizeGiftAmount(request.GiftAmount);
var paymentMethod = MemberStoredCardMapping.ParsePaymentMethod(request.PaymentMethod);
var remark = MemberStoredCardMapping.NormalizeOptionalRemark(request.Remark);
var rechargedAt = request.RechargedAt.HasValue
? MemberStoredCardMapping.NormalizeUtc(request.RechargedAt.Value)
: DateTime.UtcNow;
MemberStoredCardPlan? plan = null;
if (request.PlanId.HasValue && request.PlanId.Value > 0)
{
plan = await storedCardRepository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
if (plan.Status != MemberStoredCardPlanStatus.Enabled)
{
throw new BusinessException(ErrorCodes.BadRequest, "储值卡方案未启用");
}
if (plan.RechargeAmount != rechargeAmount || plan.GiftAmount != giftAmount)
{
throw new BusinessException(ErrorCodes.BadRequest, "充值金额与储值卡方案不一致");
}
}
var member = await memberRepository.FindProfileByIdAsync(
tenantId,
request.MemberId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
var arrivedAmount = decimal.Round(rechargeAmount + giftAmount, 2, MidpointRounding.AwayFromZero);
var record = new MemberStoredCardRechargeRecord
{
StoreId = request.StoreId,
MemberId = member.Id,
PlanId = plan?.Id,
RecordNo = MemberStoredCardMapping.BuildRechargeRecordNo(rechargedAt),
MemberName = MemberStoredCardMapping.ResolveMemberName(member),
MemberMobileMasked = MemberStoredCardMapping.ResolveMemberMobileMasked(member),
RechargeAmount = rechargeAmount,
GiftAmount = giftAmount,
ArrivedAmount = arrivedAmount,
PaymentMethod = paymentMethod,
Remark = remark,
RechargedAt = rechargedAt
};
member.StoredRechargeBalance = decimal.Round(member.StoredRechargeBalance + rechargeAmount, 2, MidpointRounding.AwayFromZero);
member.StoredGiftBalance = decimal.Round(member.StoredGiftBalance + giftAmount, 2, MidpointRounding.AwayFromZero);
member.StoredBalance = decimal.Round(member.StoredRechargeBalance + member.StoredGiftBalance, 2, MidpointRounding.AwayFromZero);
await memberRepository.UpdateProfileAsync(member, cancellationToken);
await storedCardRepository.AddRechargeRecordAsync(record, cancellationToken);
await storedCardRepository.SaveChangesAsync(cancellationToken);
return MemberStoredCardDtoFactory.ToRechargeRecordDto(record);
}
}

View File

@@ -0,0 +1,87 @@
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
namespace TakeoutSaaS.Application.App.Members.StoredCard;
/// <summary>
/// 储值卡 DTO 构造器。
/// </summary>
internal static class MemberStoredCardDtoFactory
{
public static MemberStoredCardPlanAggregateSnapshot EmptyAggregate(long planId)
{
return new MemberStoredCardPlanAggregateSnapshot
{
PlanId = planId,
RechargeCount = 0,
TotalRechargeAmount = 0m
};
}
public static MemberStoredCardPlanDto ToPlanDto(
MemberStoredCardPlan source,
MemberStoredCardPlanAggregateSnapshot aggregate)
{
return new MemberStoredCardPlanDto
{
PlanId = source.Id,
RechargeAmount = decimal.Round(source.RechargeAmount, 2, MidpointRounding.AwayFromZero),
GiftAmount = decimal.Round(source.GiftAmount, 2, MidpointRounding.AwayFromZero),
ArrivedAmount = decimal.Round(source.RechargeAmount + source.GiftAmount, 2, MidpointRounding.AwayFromZero),
SortOrder = source.SortOrder,
Status = MemberStoredCardMapping.ToPlanStatusText(source.Status),
RechargeCount = aggregate.RechargeCount,
TotalRechargeAmount = decimal.Round(aggregate.TotalRechargeAmount, 2, MidpointRounding.AwayFromZero)
};
}
public static MemberStoredCardPlanStatsDto ToPlanStatsDto(MemberStoredCardPlanStatsSnapshot source)
{
return new MemberStoredCardPlanStatsDto
{
TotalRechargeAmount = decimal.Round(source.TotalRechargeAmount, 2, MidpointRounding.AwayFromZero),
TotalGiftAmount = decimal.Round(source.TotalGiftAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthRechargeAmount = decimal.Round(source.CurrentMonthRechargeAmount, 2, MidpointRounding.AwayFromZero),
RechargeMemberCount = source.RechargeMemberCount
};
}
public static MemberStoredCardRechargeRecordDto ToRechargeRecordDto(MemberStoredCardRechargeRecord source)
{
return new MemberStoredCardRechargeRecordDto
{
RecordId = source.Id,
RecordNo = source.RecordNo,
MemberId = source.MemberId,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = decimal.Round(source.RechargeAmount, 2, MidpointRounding.AwayFromZero),
GiftAmount = decimal.Round(source.GiftAmount, 2, MidpointRounding.AwayFromZero),
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero),
PaymentMethod = MemberStoredCardMapping.ToPaymentMethodText(source.PaymentMethod),
RechargedAt = source.RechargedAt,
PlanId = source.PlanId,
Remark = source.Remark
};
}
public static MemberStoredCardPlan CreatePlanEntity(
SaveStoredCardPlanCommand request,
decimal normalizedRechargeAmount,
decimal normalizedGiftAmount,
int normalizedSortOrder,
MemberStoredCardPlanStatus status)
{
return new MemberStoredCardPlan
{
StoreId = request.StoreId,
RechargeAmount = normalizedRechargeAmount,
GiftAmount = normalizedGiftAmount,
SortOrder = normalizedSortOrder,
Status = status
};
}
}

View File

@@ -0,0 +1,222 @@
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.StoredCard;
/// <summary>
/// 储值卡模块映射与标准化。
/// </summary>
internal static class MemberStoredCardMapping
{
public static MemberStoredCardPlanStatus ParsePlanStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"enabled" => MemberStoredCardPlanStatus.Enabled,
"disabled" => MemberStoredCardPlanStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToPlanStatusText(MemberStoredCardPlanStatus value)
{
return value switch
{
MemberStoredCardPlanStatus.Enabled => "enabled",
MemberStoredCardPlanStatus.Disabled => "disabled",
_ => "disabled"
};
}
public static PaymentMethod ParsePaymentMethod(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"wechat" => PaymentMethod.WeChatPay,
"alipay" => PaymentMethod.Alipay,
"cash" => PaymentMethod.Cash,
"card" => PaymentMethod.Card,
"balance" => PaymentMethod.Balance,
_ => throw new BusinessException(ErrorCodes.BadRequest, "paymentMethod 参数不合法")
};
}
public static string ToPaymentMethodText(PaymentMethod value)
{
return value switch
{
PaymentMethod.WeChatPay => "wechat",
PaymentMethod.Alipay => "alipay",
PaymentMethod.Cash => "cash",
PaymentMethod.Card => "card",
PaymentMethod.Balance => "balance",
_ => "unknown"
};
}
public static string ToPaymentMethodDisplayText(PaymentMethod value)
{
return value switch
{
PaymentMethod.WeChatPay => "微信支付",
PaymentMethod.Alipay => "支付宝",
PaymentMethod.Cash => "现金",
PaymentMethod.Card => "刷卡",
PaymentMethod.Balance => "余额",
_ => "未知"
};
}
public static decimal NormalizeRechargeAmount(decimal value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "rechargeAmount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static decimal NormalizeGiftAmount(decimal value)
{
if (value < 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "giftAmount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static int NormalizeSortOrder(int value)
{
if (value < 0 || value > 9999)
{
throw new BusinessException(ErrorCodes.BadRequest, "sortOrder 参数不合法");
}
return value;
}
public static string? NormalizeKeyword(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "keyword 长度不能超过 64");
}
return normalized;
}
public static string? NormalizeOptionalRemark(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 256)
{
throw new BusinessException(ErrorCodes.BadRequest, "remark 长度不能超过 256");
}
return normalized;
}
public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc)
{
DateTime? normalizedStart = null;
DateTime? normalizedEnd = null;
if (startUtc.HasValue)
{
var utcValue = NormalizeUtc(startUtc.Value);
normalizedStart = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc);
}
if (endUtc.HasValue)
{
var utcValue = NormalizeUtc(endUtc.Value);
normalizedEnd = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc)
.AddDays(1)
.AddTicks(-1);
}
if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart > normalizedEnd)
{
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 BuildRechargeRecordNo(DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
return $"CZ{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
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,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 导出储值卡充值记录 CSV。
/// </summary>
public sealed class ExportStoredCardRechargeRecordCsvQuery : IRequest<MemberStoredCardRechargeRecordExportDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { 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,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 查询储值卡方案列表。
/// </summary>
public sealed class GetStoredCardPlanListQuery : IRequest<MemberStoredCardPlanListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 查询储值卡充值记录分页。
/// </summary>
public sealed class GetStoredCardRechargeRecordListQuery : IRequest<MemberStoredCardRechargeRecordListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { 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; } = 8;
}

View File

@@ -0,0 +1,35 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员储值卡充值方案。
/// </summary>
public sealed class MemberStoredCardPlan : MultiTenantEntityBase
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 排序值(越小越靠前)。
/// </summary>
public int SortOrder { get; set; } = 100;
/// <summary>
/// 启用状态。
/// </summary>
public MemberStoredCardPlanStatus Status { get; set; } = MemberStoredCardPlanStatus.Enabled;
}

View File

@@ -0,0 +1,70 @@
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员储值卡充值记录。
/// </summary>
public sealed class MemberStoredCardRechargeRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 充值方案标识(可空,表示非方案充值)。
/// </summary>
public long? PlanId { get; set; }
/// <summary>
/// 充值单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 会员名称快照。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号快照(脱敏)。
/// </summary>
public string MemberMobileMasked { get; set; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 到账金额(充值+赠送)。
/// </summary>
public decimal ArrivedAmount { get; set; }
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod PaymentMethod { get; set; } = PaymentMethod.Unknown;
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 充值时间UTC
/// </summary>
public DateTime RechargedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 储值卡方案状态。
/// </summary>
public enum MemberStoredCardPlanStatus
{
/// <summary>
/// 已停用。
/// </summary>
Disabled = 0,
/// <summary>
/// 已启用。
/// </summary>
Enabled = 1
}

View File

@@ -0,0 +1,141 @@
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
namespace TakeoutSaaS.Domain.Membership.Repositories;
/// <summary>
/// 储值卡模块仓储契约。
/// </summary>
public interface IStoredCardRepository
{
/// <summary>
/// 查询门店下全部储值方案。
/// </summary>
Task<IReadOnlyList<MemberStoredCardPlan>> GetPlansByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询储值方案。
/// </summary>
Task<MemberStoredCardPlan?> FindPlanByIdAsync(
long tenantId,
long storeId,
long planId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增储值方案。
/// </summary>
Task AddPlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新储值方案。
/// </summary>
Task UpdatePlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default);
/// <summary>
/// 删除储值方案。
/// </summary>
Task DeletePlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default);
/// <summary>
/// 按方案聚合充值次数与金额。
/// </summary>
Task<Dictionary<long, MemberStoredCardPlanAggregateSnapshot>> GetPlanAggregatesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> planIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取储值方案页统计快照。
/// </summary>
Task<MemberStoredCardPlanStatsSnapshot> GetPlanStatsAsync(
long tenantId,
long storeId,
DateTime nowUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询充值记录分页。
/// </summary>
Task<(IReadOnlyList<MemberStoredCardRechargeRecord> Items, int TotalCount)> SearchRechargeRecordsAsync(
long tenantId,
long storeId,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询充值记录导出数据。
/// </summary>
Task<IReadOnlyList<MemberStoredCardRechargeRecord>> ListRechargeRecordsForExportAsync(
long tenantId,
long storeId,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增充值记录。
/// </summary>
Task AddRechargeRecordAsync(MemberStoredCardRechargeRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 储值方案聚合快照。
/// </summary>
public sealed record MemberStoredCardPlanAggregateSnapshot
{
/// <summary>
/// 方案标识。
/// </summary>
public required long PlanId { get; init; }
/// <summary>
/// 充值次数。
/// </summary>
public int RechargeCount { get; init; }
/// <summary>
/// 充值总额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
}
/// <summary>
/// 储值卡统计快照。
/// </summary>
public sealed record MemberStoredCardPlanStatsSnapshot
{
/// <summary>
/// 储值总额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
/// <summary>
/// 赠金总额。
/// </summary>
public decimal TotalGiftAmount { get; init; }
/// <summary>
/// 本月充值额。
/// </summary>
public decimal CurrentMonthRechargeAmount { get; init; }
/// <summary>
/// 储值用户数。
/// </summary>
public int RechargeMemberCount { get; init; }
}

View File

@@ -51,6 +51,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IMemberRepository, EfMemberRepository>();
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -402,6 +402,14 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
/// <summary>
/// 会员储值方案。
/// </summary>
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
/// <summary>
/// 会员储值充值记录。
/// </summary>
public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>();
/// <summary>
/// 会话记录。
/// </summary>
public DbSet<ChatSession> ChatSessions => Set<ChatSession>();
@@ -568,6 +576,8 @@ public sealed class TakeoutAppDbContext(
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>());
@@ -1846,6 +1856,42 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
}
private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder<MemberStoredCardPlan> builder)
{
builder.ToTable("member_stored_card_plans");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.RechargeAmount).HasPrecision(18, 2);
builder.Property(x => x.GiftAmount).HasPrecision(18, 2);
builder.Property(x => x.SortOrder).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargeAmount, x.GiftAmount }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.SortOrder });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
}
private static void ConfigureMemberStoredCardRechargeRecord(EntityTypeBuilder<MemberStoredCardRechargeRecord> builder)
{
builder.ToTable("member_stored_card_recharge_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.MemberId).IsRequired();
builder.Property(x => x.PlanId);
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
builder.Property(x => x.MemberMobileMasked).HasMaxLength(32).IsRequired();
builder.Property(x => x.RechargeAmount).HasPrecision(18, 2);
builder.Property(x => x.GiftAmount).HasPrecision(18, 2);
builder.Property(x => x.ArrivedAmount).HasPrecision(18, 2);
builder.Property(x => x.PaymentMethod).HasConversion<int>();
builder.Property(x => x.Remark).HasMaxLength(256);
builder.Property(x => x.RechargedAt).IsRequired();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.MemberId, x.RechargedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PlanId, x.RechargedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt });
}
private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder)
{
builder.ToTable("chat_sessions");

View File

@@ -0,0 +1,248 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 储值卡模块 EF Core 仓储实现。
/// </summary>
public sealed class EfStoredCardRepository(TakeoutAppDbContext context) : IStoredCardRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MemberStoredCardPlan>> GetPlansByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return await context.MemberStoredCardPlans
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.RechargeAmount)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberStoredCardPlan?> FindPlanByIdAsync(
long tenantId,
long storeId,
long planId,
CancellationToken cancellationToken = default)
{
return context.MemberStoredCardPlans
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == planId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddPlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default)
{
return context.MemberStoredCardPlans.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdatePlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default)
{
context.MemberStoredCardPlans.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeletePlanAsync(MemberStoredCardPlan entity, CancellationToken cancellationToken = default)
{
context.MemberStoredCardPlans.Remove(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<Dictionary<long, MemberStoredCardPlanAggregateSnapshot>> GetPlanAggregatesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> planIds,
CancellationToken cancellationToken = default)
{
if (planIds.Count == 0)
{
return [];
}
var aggregates = await context.MemberStoredCardRechargeRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.PlanId.HasValue &&
planIds.Contains(item.PlanId.Value))
.GroupBy(item => item.PlanId!.Value)
.Select(group => new MemberStoredCardPlanAggregateSnapshot
{
PlanId = group.Key,
RechargeCount = group.Count(),
TotalRechargeAmount = group.Sum(item => item.RechargeAmount)
})
.ToListAsync(cancellationToken);
return aggregates.ToDictionary(item => item.PlanId, item => item);
}
/// <inheritdoc />
public async Task<MemberStoredCardPlanStatsSnapshot> GetPlanStatsAsync(
long tenantId,
long storeId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(nowUtc);
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var summary = await context.MemberStoredCardRechargeRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.GroupBy(_ => 1)
.Select(group => new
{
TotalRechargeAmount = group.Sum(item => item.RechargeAmount),
TotalGiftAmount = group.Sum(item => item.GiftAmount),
CurrentMonthRechargeAmount = group
.Where(item => item.RechargedAt >= monthStart && item.RechargedAt <= utcNow)
.Sum(item => item.RechargeAmount),
RechargeMemberCount = group.Select(item => item.MemberId).Distinct().Count()
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new MemberStoredCardPlanStatsSnapshot();
}
return new MemberStoredCardPlanStatsSnapshot
{
TotalRechargeAmount = summary.TotalRechargeAmount,
TotalGiftAmount = summary.TotalGiftAmount,
CurrentMonthRechargeAmount = summary.CurrentMonthRechargeAmount,
RechargeMemberCount = summary.RechargeMemberCount
};
}
/// <inheritdoc />
public async Task<(IReadOnlyList<MemberStoredCardRechargeRecord> Items, int TotalCount)> SearchRechargeRecordsAsync(
long tenantId,
long storeId,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildRechargeRecordQuery(
tenantId,
storeId,
startUtc,
endUtc,
keyword);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.RechargedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberStoredCardRechargeRecord>> ListRechargeRecordsForExportAsync(
long tenantId,
long storeId,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
CancellationToken cancellationToken = default)
{
return await BuildRechargeRecordQuery(
tenantId,
storeId,
startUtc,
endUtc,
keyword)
.OrderByDescending(item => item.RechargedAt)
.ThenByDescending(item => item.Id)
.Take(20_000)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddRechargeRecordAsync(MemberStoredCardRechargeRecord entity, CancellationToken cancellationToken = default)
{
return context.MemberStoredCardRechargeRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<MemberStoredCardRechargeRecord> BuildRechargeRecordQuery(
long tenantId,
long storeId,
DateTime? startUtc,
DateTime? endUtc,
string? keyword)
{
var query = context.MemberStoredCardRechargeRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (startUtc.HasValue)
{
var normalizedStart = NormalizeUtc(startUtc.Value);
query = query.Where(item => item.RechargedAt >= normalizedStart);
}
if (endUtc.HasValue)
{
var normalizedEnd = NormalizeUtc(endUtc.Value);
query = query.Where(item => item.RechargedAt <= normalizedEnd);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.RecordNo, like) ||
EF.Functions.ILike(item.MemberName, like) ||
EF.Functions.ILike(item.MemberMobileMasked, like));
}
return query;
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
}

View File

@@ -0,0 +1,120 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberStoredCardModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "member_stored_card_plans",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
RechargeAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "充值金额。"),
GiftAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "赠送金额。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, comment: "排序值(越小越靠前)。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "启用状态。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_stored_card_plans", x => x.Id);
},
comment: "会员储值卡充值方案。");
migrationBuilder.CreateTable(
name: "member_stored_card_recharge_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
PlanId = table.Column<long>(type: "bigint", nullable: true, comment: "充值方案标识(可空,表示非方案充值)。"),
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "充值单号。"),
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称快照。"),
MemberMobileMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号快照(脱敏)。"),
RechargeAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "充值金额。"),
GiftAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "赠送金额。"),
ArrivedAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "到账金额(充值+赠送)。"),
PaymentMethod = table.Column<int>(type: "integer", nullable: false, comment: "支付方式。"),
Remark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"),
RechargedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "充值时间UTC。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_stored_card_recharge_records", x => x.Id);
},
comment: "会员储值卡充值记录。");
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_plans_TenantId_StoreId_RechargeAmount_Gi~",
table: "member_stored_card_plans",
columns: new[] { "TenantId", "StoreId", "RechargeAmount", "GiftAmount" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_plans_TenantId_StoreId_SortOrder",
table: "member_stored_card_plans",
columns: new[] { "TenantId", "StoreId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_plans_TenantId_StoreId_Status",
table: "member_stored_card_plans",
columns: new[] { "TenantId", "StoreId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_recharge_records_TenantId_StoreId_Member~",
table: "member_stored_card_recharge_records",
columns: new[] { "TenantId", "StoreId", "MemberId", "RechargedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_recharge_records_TenantId_StoreId_PlanId~",
table: "member_stored_card_recharge_records",
columns: new[] { "TenantId", "StoreId", "PlanId", "RechargedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_recharge_records_TenantId_StoreId_Rechar~",
table: "member_stored_card_recharge_records",
columns: new[] { "TenantId", "StoreId", "RechargedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_stored_card_recharge_records_TenantId_StoreId_Record~",
table: "member_stored_card_recharge_records",
columns: new[] { "TenantId", "StoreId", "RecordNo" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "member_stored_card_plans");
migrationBuilder.DropTable(
name: "member_stored_card_recharge_records");
}
}
}

View File

@@ -2976,6 +2976,67 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberDaySetting", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal>("ExtraDiscountRate")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)")
.HasComment("会员日额外折扣(如 9 表示 9 折)。");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasComment("是否启用会员日。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<int>("Weekday")
.HasColumnType("integer")
.HasComment("周几1-7对应周一到周日。");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("member_day_settings", null, t =>
{
t.HasComment("会员日配置。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberPointLedger", b =>
{
b.Property<long>("Id")
@@ -3116,6 +3177,21 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("integer")
.HasComment("会员状态。");
b.Property<decimal>("StoredBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("储值余额。");
b.Property<decimal>("StoredGiftBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("储值赠金余额。");
b.Property<decimal>("StoredRechargeBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("储值实充余额。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
@@ -3134,6 +3210,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("TenantId", "MemberTierId");
b.HasIndex("TenantId", "Mobile")
.IsUnique();
@@ -3143,6 +3221,252 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberProfileTag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<long>("MemberProfileId")
.HasColumnType("bigint")
.HasComment("会员标识。");
b.Property<string>("TagName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("标签名。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "MemberProfileId");
b.HasIndex("TenantId", "MemberProfileId", "TagName")
.IsUnique();
b.ToTable("member_profile_tags", null, t =>
{
t.HasComment("会员标签。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberStoredCardPlan", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal>("GiftAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("赠送金额。");
b.Property<decimal>("RechargeAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("充值金额。");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasComment("排序值(越小越靠前)。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("启用状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店标识。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "SortOrder");
b.HasIndex("TenantId", "StoreId", "Status");
b.HasIndex("TenantId", "StoreId", "RechargeAmount", "GiftAmount")
.IsUnique();
b.ToTable("member_stored_card_plans", null, t =>
{
t.HasComment("会员储值卡充值方案。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberStoredCardRechargeRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("ArrivedAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("到账金额(充值+赠送)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal>("GiftAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("赠送金额。");
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasComment("会员标识。");
b.Property<string>("MemberMobileMasked")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("会员手机号快照(脱敏)。");
b.Property<string>("MemberName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("会员名称快照。");
b.Property<int>("PaymentMethod")
.HasColumnType("integer")
.HasComment("支付方式。");
b.Property<long?>("PlanId")
.HasColumnType("bigint")
.HasComment("充值方案标识(可空,表示非方案充值)。");
b.Property<decimal>("RechargeAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("充值金额。");
b.Property<DateTime>("RechargedAt")
.HasColumnType("timestamp with time zone")
.HasComment("充值时间UTC。");
b.Property<string>("RecordNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("充值单号。");
b.Property<string>("Remark")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("备注。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店标识。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "RechargedAt");
b.HasIndex("TenantId", "StoreId", "RecordNo")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "MemberId", "RechargedAt");
b.HasIndex("TenantId", "StoreId", "PlanId", "RechargedAt");
b.ToTable("member_stored_card_recharge_records", null, t =>
{
t.HasComment("会员储值卡充值记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberTier", b =>
{
b.Property<long>("Id")
@@ -3157,6 +3481,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text")
.HasComment("等级权益JSON。");
b.Property<string>("ColorHex")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasComment("主题色。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
@@ -3173,6 +3503,20 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<int>("DowngradeWindowDays")
.HasColumnType("integer")
.HasComment("降级观察窗口天数。");
b.Property<string>("IconKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("图标键。");
b.Property<bool>("IsDefault")
.HasColumnType("boolean")
.HasComment("是否默认等级。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
@@ -3199,11 +3543,28 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<decimal?>("UpgradeAmountThreshold")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("升级累计消费门槛。");
b.Property<int?>("UpgradeOrderCountThreshold")
.HasColumnType("integer")
.HasComment("升级消费次数门槛。");
b.Property<string>("UpgradeRuleType")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasComment("升级规则类型none/amount/count/both。");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.HasIndex("TenantId", "SortOrder");
b.ToTable("member_tiers", null, t =>
{
t.HasComment("会员等级定义。");