Compare commits
5 Commits
e4f7ceeaa7
...
b0bb87d97c
| Author | SHA1 | Date | |
|---|---|---|---|
| b0bb87d97c | |||
| 39e28c1a62 | |||
| dd2ac79d48 | |||
| bd418c5927 | |||
| a8cfda88f7 |
@@ -0,0 +1,247 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <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>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 到账日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日到账。
|
||||
/// </summary>
|
||||
public decimal TodayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日到账。
|
||||
/// </summary>
|
||||
public decimal YesterdayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月到账。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月交易笔数。
|
||||
/// </summary>
|
||||
public int CurrentMonthTransactionCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementAccountResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 银行名称。
|
||||
/// </summary>
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开户名。
|
||||
/// </summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏微信商户号。
|
||||
/// </summary>
|
||||
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏支付宝 PID。
|
||||
/// </summary>
|
||||
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期文案。
|
||||
/// </summary>
|
||||
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 到账日期。
|
||||
/// </summary>
|
||||
public string ArrivedDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public int TransactionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public string PaidAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细列表。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementExportResponse
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态过滤(draft/pending/sending/sent/failed)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道过滤(inapp/sms/wechat-mini)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(标题)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(可选)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID(可选)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型(immediate/scheduled)。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = "immediate";
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交动作(draft/send)。
|
||||
/// </summary>
|
||||
public string SubmitAction { get; set; } = "draft";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageReachRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算人群请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标类型(all/tag)。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 标签。
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(模板名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板详情请求。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板请求。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类(marketing/notice/recall)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = "notice";
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteMemberMessageTemplateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月发送条数。
|
||||
/// </summary>
|
||||
public int MonthlySentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 触达人数。
|
||||
/// </summary>
|
||||
public int ReachMemberCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表项响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachListItemResponse> 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 MemberMessageReachRecipientResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenId。
|
||||
/// </summary>
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ConvertedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public List<string> AudienceTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标文案。
|
||||
/// </summary>
|
||||
public string AudienceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成功发送数。
|
||||
/// </summary>
|
||||
public int SentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读数。
|
||||
/// </summary>
|
||||
public int ReadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化数。
|
||||
/// </summary>
|
||||
public int ConvertedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率(百分比)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率(百分比)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细。
|
||||
/// </summary>
|
||||
public List<MemberMessageReachRecipientResponse> Recipients { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息调度元信息响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageDispatchMetaResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息 ID。
|
||||
/// </summary>
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用次数。
|
||||
/// </summary>
|
||||
public int UsageCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表响应。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<MemberMessageTemplateResponse> 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 MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int ReachCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled,可空)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PointMallProductId { 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 string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? 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? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <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; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <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 WritePointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RedeemedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string VerifyMethod { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleStatsResponse
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则。
|
||||
/// </summary>
|
||||
public PointMallRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRuleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <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>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? 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>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "上架";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallProductResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录响应。
|
||||
/// </summary>
|
||||
public class PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <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 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>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "已发放";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间。
|
||||
/// </summary>
|
||||
public string RedeemedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间。
|
||||
/// </summary>
|
||||
public string? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式文案。
|
||||
/// </summary>
|
||||
public string? VerifyMethodText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人 ID。
|
||||
/// </summary>
|
||||
public string? VerifiedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallRecordResponse> 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 PointMallRecordStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录导出响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordExportResponse
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心到账查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/settlement")]
|
||||
public sealed class FinanceSettlementController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:settlement:view";
|
||||
private const string ExportPermission = "tenant:finance:settlement:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
|
||||
[FromQuery] FinanceSettlementStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
|
||||
{
|
||||
TodayArrivedAmount = stats.TodayArrivedAmount,
|
||||
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
|
||||
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
|
||||
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账账户信息。
|
||||
/// </summary>
|
||||
[HttpGet("account")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
|
||||
if (account is null)
|
||||
{
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
|
||||
{
|
||||
BankName = account.BankName,
|
||||
BankAccountName = account.BankAccountName,
|
||||
BankAccountNoMasked = account.BankAccountNoMasked,
|
||||
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
|
||||
AlipayPidMasked = account.AlipayPidMasked,
|
||||
SettlementPeriodText = account.SettlementPeriodText
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账汇总列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
|
||||
[FromQuery] FinanceSettlementListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SearchFinanceSettlementListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账明细(展开行)。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
|
||||
[FromQuery] FinanceSettlementDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
|
||||
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
|
||||
|
||||
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ArrivedDate = arrivedDate,
|
||||
PaymentMethod = paymentMethod,
|
||||
Take = 50
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapDetailItem).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出到账汇总 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
|
||||
[FromQuery] FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
PaymentMethod = parsed.PaymentMethod
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceSettlementFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var startAt = ParseDateOrNull(request.StartDate);
|
||||
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
|
||||
}
|
||||
|
||||
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 ParseRequiredDate(string? value, string parameterName)
|
||||
{
|
||||
return ParseDateOrNull(value)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateOrNull(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
|
||||
{
|
||||
return ParseOptionalSettlementChannel(channel)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
|
||||
{
|
||||
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"" => null,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
|
||||
{
|
||||
return new FinanceSettlementListItemResponse
|
||||
{
|
||||
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Channel = source.Channel,
|
||||
ChannelText = source.ChannelText,
|
||||
TransactionCount = source.TransactionCount,
|
||||
ArrivedAmount = source.ArrivedAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
|
||||
{
|
||||
return new FinanceSettlementDetailItemResponse
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = source.Amount,
|
||||
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Globalization;
|
||||
using Asp.Versioning;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
using TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/member/message-reach")]
|
||||
public sealed class MemberMessageReachController(
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:message-reach:view";
|
||||
private const string ManagePermission = "tenant:member:message-reach:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取页面统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachStatsResponse>> Stats(
|
||||
[FromQuery] MemberMessageReachStatsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
|
||||
return ApiResponse<MemberMessageReachStatsResponse>.Ok(new MemberMessageReachStatsResponse
|
||||
{
|
||||
MonthlySentCount = result.MonthlySentCount,
|
||||
ReachMemberCount = result.ReachMemberCount,
|
||||
OpenRate = result.OpenRate,
|
||||
ConversionRate = result.ConversionRate
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询消息列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachListResultResponse>> List(
|
||||
[FromQuery] MemberMessageReachListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchMessagesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageInput
|
||||
{
|
||||
Status = request.Status,
|
||||
Channel = request.Channel,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageReachListResultResponse>.Ok(new MemberMessageReachListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapMessageListItem).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取消息详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageReachDetailResponse>> Detail(
|
||||
[FromQuery] MemberMessageReachDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Error(ErrorCodes.NotFound, "消息不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageReachDetailResponse>.Ok(MapMessageDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息(草稿/发送)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageDispatchMetaResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageDispatchMetaResponse>> Save(
|
||||
[FromBody] SaveMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||
var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId);
|
||||
var previousMeta = messageId.HasValue
|
||||
? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
var saved = await memberMessageReachAppService.SaveMessageAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageInput
|
||||
{
|
||||
MessageId = messageId,
|
||||
StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId),
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Title = request.Title,
|
||||
Content = request.Content,
|
||||
Channels = request.Channels,
|
||||
AudienceType = request.AudienceType,
|
||||
AudienceTags = request.AudienceTags,
|
||||
ScheduleType = request.ScheduleType,
|
||||
ScheduledAt = request.ScheduledAt,
|
||||
SubmitAction = request.SubmitAction
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
// 1. 清理旧任务(若存在)。
|
||||
if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId))
|
||||
{
|
||||
BackgroundJob.Delete(previousMeta.HangfireJobId);
|
||||
}
|
||||
|
||||
// 2. 发送动作创建新任务并回写任务 ID。
|
||||
if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt);
|
||||
await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. 返回最新调度状态。
|
||||
var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken);
|
||||
return ApiResponse<MemberMessageDispatchMetaResponse>.Ok(MapDispatchMeta(latest ?? saved));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteMemberMessageReachRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||
var oldJobId = await memberMessageReachAppService.DeleteMessageAsync(tenantId, messageId, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(oldJobId))
|
||||
{
|
||||
BackgroundJob.Delete(oldJobId);
|
||||
}
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算目标人群。
|
||||
/// </summary>
|
||||
[HttpPost("audience/estimate")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageAudienceEstimateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageAudienceEstimateResponse>> EstimateAudience(
|
||||
[FromBody] MemberMessageAudienceEstimateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.EstimateAudienceAsync(
|
||||
tenantId,
|
||||
new MemberMessageAudienceEstimateInput
|
||||
{
|
||||
AudienceType = request.AudienceType,
|
||||
Tags = request.Tags
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageAudienceEstimateResponse>.Ok(new MemberMessageAudienceEstimateResponse
|
||||
{
|
||||
ReachCount = result.ReachCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询模板。
|
||||
/// </summary>
|
||||
[HttpGet("template/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateListResultResponse>> TemplateList(
|
||||
[FromQuery] MemberMessageTemplateListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
new SearchMemberMessageTemplateInput
|
||||
{
|
||||
Category = request.Category,
|
||||
Keyword = request.Keyword,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateListResultResponse>.Ok(new MemberMessageTemplateListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapTemplate).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取模板详情。
|
||||
/// </summary>
|
||||
[HttpGet("template/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> TemplateDetail(
|
||||
[FromQuery] MemberMessageTemplateDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
var result = await memberMessageReachAppService.GetTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Error(ErrorCodes.NotFound, "模板不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MemberMessageTemplateResponse>> SaveTemplate(
|
||||
[FromBody] SaveMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var result = await memberMessageReachAppService.SaveTemplateAsync(
|
||||
tenantId,
|
||||
new SaveMemberMessageTemplateInput
|
||||
{
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||
Name = request.Name,
|
||||
Category = request.Category,
|
||||
Content = request.Content
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板。
|
||||
/// </summary>
|
||||
[HttpPost("template/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteTemplate(
|
||||
[FromBody] DeleteMemberMessageTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||
await memberMessageReachAppService.DeleteTemplateAsync(tenantId, templateId, cancellationToken);
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
private long ResolveTenantId()
|
||||
{
|
||||
var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private async Task<long> ResolveTenantIdAsync(string? storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
if (string.IsNullOrWhiteSpace(storeId))
|
||||
{
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreId,
|
||||
cancellationToken);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private static string ScheduleDispatchJob(long messageId, string scheduleType, DateTime? scheduledAtUtc)
|
||||
{
|
||||
if (string.Equals(scheduleType, "scheduled", StringComparison.OrdinalIgnoreCase) && scheduledAtUtc.HasValue)
|
||||
{
|
||||
var delay = scheduledAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
|
||||
if (delay < TimeSpan.Zero)
|
||||
{
|
||||
delay = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return BackgroundJob.Schedule<MemberMessageReachDispatchJobRunner>(
|
||||
runner => runner.ExecuteAsync(messageId),
|
||||
delay);
|
||||
}
|
||||
|
||||
return BackgroundJob.Enqueue<MemberMessageReachDispatchJobRunner>(runner => runner.ExecuteAsync(messageId));
|
||||
}
|
||||
|
||||
private static MemberMessageReachListItemResponse MapMessageListItem(MemberMessageReachListItemDto source)
|
||||
{
|
||||
return new MemberMessageReachListItemResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Title = source.Title,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageReachDetailResponse MapMessageDetail(MemberMessageReachDetailDto source)
|
||||
{
|
||||
return new MemberMessageReachDetailResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
TemplateId = source.TemplateId?.ToString(),
|
||||
Title = source.Title,
|
||||
Content = source.Content,
|
||||
Channels = source.Channels.ToList(),
|
||||
AudienceType = source.AudienceType,
|
||||
AudienceTags = source.AudienceTags.ToList(),
|
||||
AudienceText = source.AudienceText,
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
Status = source.Status,
|
||||
SentAt = FormatDateTime(source.SentAt),
|
||||
SentCount = source.SentCount,
|
||||
ReadCount = source.ReadCount,
|
||||
ConvertedCount = source.ConvertedCount,
|
||||
OpenRate = source.OpenRate,
|
||||
ConversionRate = source.ConversionRate,
|
||||
LastError = source.LastError,
|
||||
Recipients = source.Recipients.Select(item => new MemberMessageReachRecipientResponse
|
||||
{
|
||||
MemberId = item.MemberId.ToString(),
|
||||
Channel = item.Channel,
|
||||
Status = item.Status,
|
||||
Mobile = item.Mobile,
|
||||
OpenId = item.OpenId,
|
||||
SentAt = FormatDateTime(item.SentAt),
|
||||
ReadAt = FormatDateTime(item.ReadAt),
|
||||
ConvertedAt = FormatDateTime(item.ConvertedAt),
|
||||
ErrorMessage = item.ErrorMessage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageDispatchMetaResponse MapDispatchMeta(MemberMessageDispatchMetaDto source)
|
||||
{
|
||||
return new MemberMessageDispatchMetaResponse
|
||||
{
|
||||
MessageId = source.MessageId.ToString(),
|
||||
Status = source.Status,
|
||||
ScheduleType = source.ScheduleType,
|
||||
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||
HangfireJobId = source.HangfireJobId
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageTemplateResponse MapTemplate(MemberMessageTemplateDto source)
|
||||
{
|
||||
return new MemberMessageTemplateResponse
|
||||
{
|
||||
TemplateId = source.TemplateId.ToString(),
|
||||
Name = source.Name,
|
||||
Category = source.Category,
|
||||
Content = source.Content,
|
||||
UsageCount = source.UsageCount,
|
||||
LastUsedAt = FormatDateTime(source.LastUsedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatDateTime(DateTime? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.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/points-mall")]
|
||||
public sealed class MemberPointsMallController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService)
|
||||
: BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:member:points-mall:view";
|
||||
private const string ManagePermission = "tenant:member:points-mall:manage";
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分规则详情。
|
||||
/// </summary>
|
||||
[HttpGet("rule/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> RuleDetail(
|
||||
[FromQuery] PointMallRuleDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRuleDetailQuery
|
||||
{
|
||||
StoreId = storeId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleDetailResultResponse>.Ok(new PointMallRuleDetailResultResponse
|
||||
{
|
||||
Rule = MapRule(result.Rule),
|
||||
Stats = new PointMallRuleStatsResponse
|
||||
{
|
||||
TotalIssuedPoints = result.Stats.TotalIssuedPoints,
|
||||
RedeemedPoints = result.Stats.RedeemedPoints,
|
||||
PointMembers = result.Stats.PointMembers,
|
||||
RedeemRate = result.Stats.RedeemRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分规则。
|
||||
/// </summary>
|
||||
[HttpPost("rule/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleResponse>> SaveRule(
|
||||
[FromBody] SavePointMallRuleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallRuleCommand
|
||||
{
|
||||
StoreId = 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 = request.ExpiryMode
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRuleResponse>.Ok(MapRule(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
[HttpGet("product/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductListResultResponse>> ProductList(
|
||||
[FromQuery] PointMallProductListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Status = request.Status,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapProduct).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品详情。
|
||||
/// </summary>
|
||||
[HttpGet("product/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ProductDetail(
|
||||
[FromQuery] PointMallProductDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallProductDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> SaveProduct(
|
||||
[FromBody] SavePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new SavePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId),
|
||||
Name = request.Name,
|
||||
ImageUrl = request.ImageUrl,
|
||||
RedeemType = request.RedeemType,
|
||||
ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId),
|
||||
CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId),
|
||||
PhysicalName = request.PhysicalName,
|
||||
PickupMethod = request.PickupMethod,
|
||||
Description = request.Description,
|
||||
ExchangeType = request.ExchangeType,
|
||||
RequiredPoints = request.RequiredPoints,
|
||||
CashAmount = request.CashAmount,
|
||||
StockTotal = request.StockTotal,
|
||||
PerMemberLimit = request.PerMemberLimit,
|
||||
NotifyChannels = request.NotifyChannels,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改兑换商品状态。
|
||||
/// </summary>
|
||||
[HttpPost("product/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> ChangeProductStatus(
|
||||
[FromBody] ChangePointMallProductStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ChangePointMallProductStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteProduct(
|
||||
[FromBody] DeletePointMallProductRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
await mediator.Send(new DeletePointMallProductCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordListResultResponse>> RecordList(
|
||||
[FromQuery] PointMallRecordListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
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<PointMallRecordListResultResponse>.Ok(new PointMallRecordListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapRecord).ToList(),
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
TotalCount = result.TotalCount,
|
||||
Stats = new PointMallRecordStatsResponse
|
||||
{
|
||||
TodayRedeemCount = result.Stats.TodayRedeemCount,
|
||||
PendingPhysicalCount = result.Stats.PendingPhysicalCount,
|
||||
CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> RecordDetail(
|
||||
[FromQuery] PointMallRecordDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new GetPointMallRecordDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出兑换记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordExportResponse>> ExportRecord(
|
||||
[FromQuery] ExportPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new ExportPointMallRecordCsvQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
RedeemType = request.RedeemType,
|
||||
Status = request.Status,
|
||||
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordResponse>> WriteRecord(
|
||||
[FromBody] WritePointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new WritePointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||
RedeemedAt = request.RedeemedAt
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/verify")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> VerifyRecord(
|
||||
[FromBody] VerifyPointMallRecordRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
var result = await mediator.Send(new VerifyPointMallRecordCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||
VerifyMethod = request.VerifyMethod,
|
||||
VerifyRemark = request.VerifyRemark
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(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 PointMallRuleResponse MapRule(MemberPointMallRuleDto source)
|
||||
{
|
||||
return new PointMallRuleResponse
|
||||
{
|
||||
StoreId = source.StoreId.ToString(),
|
||||
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 = source.ExpiryMode
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallProductResponse MapProduct(MemberPointMallProductDto source)
|
||||
{
|
||||
return new PointMallProductResponse
|
||||
{
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
StoreId = source.StoreId.ToString(),
|
||||
Name = source.Name,
|
||||
ImageUrl = source.ImageUrl,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ProductId = source.ProductId?.ToString(),
|
||||
CouponTemplateId = source.CouponTemplateId?.ToString(),
|
||||
PhysicalName = source.PhysicalName,
|
||||
PickupMethod = source.PickupMethod,
|
||||
Description = source.Description,
|
||||
ExchangeType = source.ExchangeType,
|
||||
RequiredPoints = source.RequiredPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
StockTotal = source.StockTotal,
|
||||
StockAvailable = source.StockAvailable,
|
||||
RedeemedCount = source.RedeemedCount,
|
||||
PerMemberLimit = source.PerMemberLimit,
|
||||
NotifyChannels = source.NotifyChannels.ToList(),
|
||||
Status = source.Status,
|
||||
StatusText = ResolveProductStatusText(source.Status),
|
||||
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source)
|
||||
{
|
||||
return new PointMallRecordResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source)
|
||||
{
|
||||
var response = new PointMallRecordDetailResponse
|
||||
{
|
||||
RecordId = source.RecordId.ToString(),
|
||||
RecordNo = source.RecordNo,
|
||||
PointMallProductId = source.PointMallProductId.ToString(),
|
||||
ProductName = source.ProductName,
|
||||
RedeemType = source.RedeemType,
|
||||
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||
ExchangeType = source.ExchangeType,
|
||||
MemberId = source.MemberId.ToString(),
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
UsedPoints = source.UsedPoints,
|
||||
CashAmount = source.CashAmount,
|
||||
Status = source.Status,
|
||||
StatusText = ResolveRecordStatusText(source.Status),
|
||||
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
VerifyMethod = source.VerifyMethod,
|
||||
VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod),
|
||||
VerifyRemark = source.VerifyRemark,
|
||||
VerifiedBy = source.VerifiedBy?.ToString()
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ResolveRedeemTypeText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"product" => "商品",
|
||||
"coupon" => "优惠券",
|
||||
"physical" => "实物",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveProductStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"enabled" => "上架",
|
||||
"disabled" => "下架",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRecordStatusText(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending_pickup" => "待领取",
|
||||
"issued" => "已发放",
|
||||
"completed" => "已完成",
|
||||
"canceled" => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveVerifyMethodText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"scan" => "扫码核销",
|
||||
"manual" => "手动核销",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ using Serilog;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.App.Common.Geo;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Application.Sms.Extensions;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
@@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
using TakeoutSaaS.Module.Scheduler.Extensions;
|
||||
using TakeoutSaaS.Module.Sms.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
@@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
|
||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddSmsApplication(builder.Configuration);
|
||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||
@@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddSmsModule(builder.Configuration);
|
||||
builder.Services.AddMassTransit(configurator =>
|
||||
{
|
||||
// 注册 SignalR 推送消费者
|
||||
@@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator =>
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStorageApplication();
|
||||
builder.Services.AddSchedulerModule(builder.Configuration);
|
||||
builder.Services.AddOptions<MemberMessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("MemberMessaging"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddHttpClient<IMemberMessageWeChatSender, MemberMessageWeChatSender>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddScoped<MemberMessageReachDispatchJobRunner>();
|
||||
|
||||
// 9.1 注册腾讯地图地理编码服务(服务端签名)
|
||||
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达发送任务执行器。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDispatchJobRunner(
|
||||
TakeoutAppDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IMemberMessageReachAppService memberMessageReachAppService,
|
||||
ILogger<MemberMessageReachDispatchJobRunner> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行消息发送任务。
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
public async Task ExecuteAsync(long messageId)
|
||||
{
|
||||
// 1. 查询任务所属租户,避免跨租户执行。
|
||||
var jobMeta = await dbContext.MemberReachMessages
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == messageId)
|
||||
.Select(item => new JobMeta(item.Id, item.TenantId))
|
||||
.SingleOrDefaultAsync();
|
||||
if (jobMeta is null || jobMeta.TenantId <= 0)
|
||||
{
|
||||
logger.LogWarning("会员消息任务不存在或租户无效,MessageId={MessageId}", messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 切换租户作用域并执行发送逻辑。
|
||||
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
|
||||
try
|
||||
{
|
||||
await memberMessageReachAppService.ExecuteDispatchAsync(jobMeta.TenantId, jobMeta.Id, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "会员消息任务执行失败,TenantId={TenantId} MessageId={MessageId}", jobMeta.TenantId, jobMeta.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record JobMeta(long Id, long TenantId);
|
||||
}
|
||||
@@ -125,6 +125,49 @@
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": true,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"WorkerCount": 10,
|
||||
|
||||
@@ -123,6 +123,49 @@
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": false,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID",
|
||||
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"MemberMessaging": {
|
||||
"SmsScene": "member_message",
|
||||
"WeChatMini": {
|
||||
"AppId": "WECHAT_MINI_APP_ID",
|
||||
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||
"PagePath": "pages/member/message-center/index",
|
||||
"TitleDataKey": "thing1",
|
||||
"ContentDataKey": "thing2"
|
||||
}
|
||||
},
|
||||
"Scheduler": {
|
||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"WorkerCount": 10,
|
||||
|
||||
@@ -3,6 +3,7 @@ using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
using TakeoutSaaS.Application.App.Common.Behaviors;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
@@ -35,6 +36,9 @@ public static class AppApplicationServiceCollectionExtensions
|
||||
// 2. 注册门店模块上下文服务
|
||||
services.AddScoped<StoreContextService>();
|
||||
|
||||
// 3. (空行后) 注册会员消息触达服务
|
||||
services.AddScoped<IMemberMessageReachAppService, MemberMessageReachAppService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询汇总行 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 到账日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime ArrivedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码(wechat/alipay)。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public int TransactionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal ArrivedAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询分页结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementListItemDto> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细行 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PaidAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementDetailResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细列表。
|
||||
/// </summary>
|
||||
public List<FinanceSettlementDetailItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日到账金额。
|
||||
/// </summary>
|
||||
public decimal TodayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日到账金额。
|
||||
/// </summary>
|
||||
public decimal YesterdayArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月到账金额。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月交易笔数。
|
||||
/// </summary>
|
||||
public int CurrentMonthTransactionCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementAccountDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 银行名称。
|
||||
/// </summary>
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开户名。
|
||||
/// </summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏微信商户号。
|
||||
/// </summary>
|
||||
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏支付宝 PID。
|
||||
/// </summary>
|
||||
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期文案。
|
||||
/// </summary>
|
||||
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账导出 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceSettlementExportDto
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账汇总导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceSettlementCsvQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportFinanceSettlementCsvQuery, FinanceSettlementExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceSettlementExportDto> Handle(
|
||||
ExportFinanceSettlementCsvQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var rows = await financeTransactionRepository.ListSettlementForExportAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.PaymentMethod,
|
||||
cancellationToken);
|
||||
|
||||
var list = rows.Select(FinanceSettlementMapping.ToListItem).ToList();
|
||||
var csv = BuildCsv(list);
|
||||
return new FinanceSettlementExportDto
|
||||
{
|
||||
FileName = $"settlement-{request.StoreId}-{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(csv)),
|
||||
TotalCount = list.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCsv(IReadOnlyList<FinanceSettlementListItemDto> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('\uFEFF');
|
||||
sb.AppendLine("到账日期,支付渠道,交易笔数,到账金额");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join(',',
|
||||
Escape(row.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||
Escape(row.ChannelText),
|
||||
Escape(row.TransactionCount.ToString(CultureInfo.InvariantCulture)),
|
||||
Escape(FinanceSettlementMapping.FormatAmount(row.ArrivedAmount))));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string? value)
|
||||
{
|
||||
var normalized = value ?? string.Empty;
|
||||
if (normalized.Contains(',') || normalized.Contains('"') || normalized.Contains('\n'))
|
||||
{
|
||||
return $"\"{normalized.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询映射辅助。
|
||||
/// </summary>
|
||||
internal static class FinanceSettlementMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付方式转渠道编码。
|
||||
/// </summary>
|
||||
public static string ToChannelCode(PaymentMethod paymentMethod)
|
||||
{
|
||||
return paymentMethod switch
|
||||
{
|
||||
PaymentMethod.WeChatPay => "wechat",
|
||||
PaymentMethod.Alipay => "alipay",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式转渠道文案。
|
||||
/// </summary>
|
||||
public static string ToChannelText(PaymentMethod paymentMethod)
|
||||
{
|
||||
return paymentMethod switch
|
||||
{
|
||||
PaymentMethod.WeChatPay => "微信支付",
|
||||
PaymentMethod.Alipay => "支付宝",
|
||||
_ => "未知渠道"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射到账汇总行。
|
||||
/// </summary>
|
||||
public static FinanceSettlementListItemDto ToListItem(FinanceSettlementListItemSnapshot source)
|
||||
{
|
||||
return new FinanceSettlementListItemDto
|
||||
{
|
||||
ArrivedDate = source.ArrivedDate,
|
||||
Channel = ToChannelCode(source.PaymentMethod),
|
||||
ChannelText = ToChannelText(source.PaymentMethod),
|
||||
TransactionCount = source.TransactionCount,
|
||||
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射到账明细行。
|
||||
/// </summary>
|
||||
public static FinanceSettlementDetailItemDto ToDetailItem(FinanceSettlementDetailItemSnapshot source)
|
||||
{
|
||||
return new FinanceSettlementDetailItemDto
|
||||
{
|
||||
OrderNo = source.OrderNo,
|
||||
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
|
||||
PaidAt = source.PaidAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化金额(导出场景)。
|
||||
/// </summary>
|
||||
public static string FormatAmount(decimal value)
|
||||
{
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero)
|
||||
.ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementAccountQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceSettlementAccountQuery, FinanceSettlementAccountDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceSettlementAccountDto?> Handle(
|
||||
GetFinanceSettlementAccountQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var snapshot = await financeTransactionRepository.GetSettlementAccountAsync(
|
||||
tenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FinanceSettlementAccountDto
|
||||
{
|
||||
BankName = snapshot.BankName,
|
||||
BankAccountName = snapshot.BankAccountName,
|
||||
BankAccountNoMasked = snapshot.BankAccountNoMasked,
|
||||
WechatMerchantNoMasked = snapshot.WechatMerchantNoMasked,
|
||||
AlipayPidMasked = snapshot.AlipayPidMasked,
|
||||
SettlementPeriodText = snapshot.SettlementPeriodText
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账明细查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementDetailQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceSettlementDetailQuery, FinanceSettlementDetailResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceSettlementDetailResultDto> Handle(
|
||||
GetFinanceSettlementDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var rows = await financeTransactionRepository.GetSettlementDetailsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.ArrivedDate,
|
||||
request.PaymentMethod,
|
||||
request.Take,
|
||||
cancellationToken);
|
||||
|
||||
return new FinanceSettlementDetailResultDto
|
||||
{
|
||||
Items = rows.Select(FinanceSettlementMapping.ToDetailItem).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账统计查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementStatsQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceSettlementStatsQuery, FinanceSettlementStatsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceSettlementStatsDto> Handle(
|
||||
GetFinanceSettlementStatsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var snapshot = await financeTransactionRepository.GetSettlementStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
DateTime.UtcNow,
|
||||
cancellationToken);
|
||||
|
||||
return new FinanceSettlementStatsDto
|
||||
{
|
||||
TodayArrivedAmount = snapshot.TodayArrivedAmount,
|
||||
YesterdayArrivedAmount = snapshot.YesterdayArrivedAmount,
|
||||
CurrentMonthArrivedAmount = snapshot.CurrentMonthArrivedAmount,
|
||||
CurrentMonthTransactionCount = snapshot.CurrentMonthTransactionCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 到账汇总分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceSettlementListQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchFinanceSettlementListQuery, FinanceSettlementListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceSettlementListResultDto> Handle(
|
||||
SearchFinanceSettlementListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.Page);
|
||||
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
var snapshot = await financeTransactionRepository.SearchSettlementPageAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.PaymentMethod,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new FinanceSettlementListResultDto
|
||||
{
|
||||
Items = snapshot.Items.Select(FinanceSettlementMapping.ToListItem).ToList(),
|
||||
Total = snapshot.TotalCount,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出到账汇总 CSV。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceSettlementCsvQuery : IRequest<FinanceSettlementExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(UTC,闭区间)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(UTC,开区间)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式筛选。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账账户信息。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementAccountQuery : IRequest<FinanceSettlementAccountDto?>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账明细。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementDetailQuery : IRequest<FinanceSettlementDetailResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime ArrivedDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(微信/支付宝)。
|
||||
/// </summary>
|
||||
public PaymentMethod PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 限制条数。
|
||||
/// </summary>
|
||||
public int Take { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账统计。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceSettlementStatsQuery : IRequest<FinanceSettlementStatsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账汇总分页。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceSettlementListQuery : IRequest<FinanceSettlementListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(UTC,闭区间)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(UTC,开区间)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式筛选。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 消息触达统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月发送消息条数。
|
||||
/// </summary>
|
||||
public int MonthlySentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月触达人数。
|
||||
/// </summary>
|
||||
public int ReachMemberCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberMessageReachListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息标识。
|
||||
/// </summary>
|
||||
public long MessageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息标题。
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标描述。
|
||||
/// </summary>
|
||||
public string AudienceText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息标识。
|
||||
/// </summary>
|
||||
public long MessageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板标识。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息标题。
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消息正文。
|
||||
/// </summary>
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AudienceTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标描述。
|
||||
/// </summary>
|
||||
public string AudienceText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送成功数量。
|
||||
/// </summary>
|
||||
public int SentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读数量。
|
||||
/// </summary>
|
||||
public int ReadCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化数量。
|
||||
/// </summary>
|
||||
public int ConvertedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal OpenRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化率百分比(0-100)。
|
||||
/// </summary>
|
||||
public decimal ConversionRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后错误信息。
|
||||
/// </summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberMessageReachRecipientDto> Recipients { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收件明细 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachRecipientDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public string Channel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号快照。
|
||||
/// </summary>
|
||||
public string? Mobile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenId 快照。
|
||||
/// </summary>
|
||||
public string? OpenId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ReadAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ConvertedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败信息。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberMessageTemplateDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模板 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板标识。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类。
|
||||
/// </summary>
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用次数。
|
||||
/// </summary>
|
||||
public int UsageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近使用时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastUsedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标人群估算 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int ReachCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息调度元信息 DTO。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageDispatchMetaDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息标识。
|
||||
/// </summary>
|
||||
public long MessageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息请求输入。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息标识。
|
||||
/// </summary>
|
||||
public long? MessageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板标识。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AudienceTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public string ScheduleType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交动作(draft/send)。
|
||||
/// </summary>
|
||||
public string SubmitAction { get; init; } = "draft";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索消息输入。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberMessageInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态过滤。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道过滤。
|
||||
/// </summary>
|
||||
public string? Channel { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索模板输入。
|
||||
/// </summary>
|
||||
public sealed class SearchMemberMessageTemplateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string? Category { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板输入。
|
||||
/// </summary>
|
||||
public sealed class SaveMemberMessageTemplateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板标识。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类。
|
||||
/// </summary>
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算人群输入。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageAudienceEstimateInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string AudienceType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标签列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
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.MessageReach;
|
||||
|
||||
internal static class MemberMessageReachMapping
|
||||
{
|
||||
internal static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
internal static MemberMessageAudienceType ParseAudienceType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"all" => MemberMessageAudienceType.All,
|
||||
"tag" => MemberMessageAudienceType.Tags,
|
||||
"tags" => MemberMessageAudienceType.Tags,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "audienceType 非法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToAudienceTypeText(MemberMessageAudienceType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageAudienceType.All => "all",
|
||||
MemberMessageAudienceType.Tags => "tag",
|
||||
_ => "all"
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageScheduleType ParseScheduleType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"immediate" => MemberMessageScheduleType.Immediate,
|
||||
"scheduled" => MemberMessageScheduleType.Scheduled,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scheduleType 非法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToScheduleTypeText(MemberMessageScheduleType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageScheduleType.Immediate => "immediate",
|
||||
MemberMessageScheduleType.Scheduled => "scheduled",
|
||||
_ => "immediate"
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageStatus ParseStatusOrNull(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"draft" => MemberMessageStatus.Draft,
|
||||
"pending" => MemberMessageStatus.Pending,
|
||||
"sending" => MemberMessageStatus.Sending,
|
||||
"sent" => MemberMessageStatus.Sent,
|
||||
"failed" => MemberMessageStatus.Failed,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageStatus? TryParseStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseStatusOrNull(value);
|
||||
}
|
||||
|
||||
internal static string ToStatusText(MemberMessageStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageStatus.Draft => "draft",
|
||||
MemberMessageStatus.Pending => "pending",
|
||||
MemberMessageStatus.Sending => "sending",
|
||||
MemberMessageStatus.Sent => "sent",
|
||||
MemberMessageStatus.Failed => "failed",
|
||||
_ => "draft"
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageChannel ParseChannel(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"inapp" => MemberMessageChannel.InApp,
|
||||
"sms" => MemberMessageChannel.Sms,
|
||||
"wechat-mini" => MemberMessageChannel.WeChatMini,
|
||||
"wechat" => MemberMessageChannel.WeChatMini,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 非法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageChannel? TryParseChannel(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseChannel(value);
|
||||
}
|
||||
|
||||
internal static string ToChannelText(MemberMessageChannel value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageChannel.InApp => "inapp",
|
||||
MemberMessageChannel.Sms => "sms",
|
||||
MemberMessageChannel.WeChatMini => "wechat-mini",
|
||||
_ => "inapp"
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToRecipientStatusText(MemberMessageRecipientStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageRecipientStatus.Pending => "pending",
|
||||
MemberMessageRecipientStatus.Sent => "sent",
|
||||
MemberMessageRecipientStatus.Failed => "failed",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageTemplateCategory ParseTemplateCategory(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"marketing" => MemberMessageTemplateCategory.Marketing,
|
||||
"notice" => MemberMessageTemplateCategory.Notice,
|
||||
"recall" => MemberMessageTemplateCategory.Recall,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageTemplateCategory? TryParseTemplateCategory(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseTemplateCategory(value);
|
||||
}
|
||||
|
||||
internal static string ToTemplateCategoryText(MemberMessageTemplateCategory value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MemberMessageTemplateCategory.Marketing => "marketing",
|
||||
MemberMessageTemplateCategory.Notice => "notice",
|
||||
MemberMessageTemplateCategory.Recall => "recall",
|
||||
_ => "notice"
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeTags(IReadOnlyList<string>? tags)
|
||||
{
|
||||
return (tags ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeChannels(IReadOnlyList<string>? channels)
|
||||
{
|
||||
var parsed = (channels ?? [])
|
||||
.Select(ParseChannel)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
|
||||
}
|
||||
|
||||
return parsed.Select(ToChannelText).ToList();
|
||||
}
|
||||
|
||||
internal static string SerializeStringArray(IReadOnlyList<string> source)
|
||||
{
|
||||
return JsonSerializer.Serialize(source, JsonOptions);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> DeserializeStringArray(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(value, JsonOptions)?
|
||||
.Select(item => (item ?? string.Empty).Trim())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList() ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
internal static decimal ResolveRatePercent(int numerator, int denominator)
|
||||
{
|
||||
if (denominator <= 0 || numerator <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return decimal.Round((decimal)numerator * 100m / denominator, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
internal static MemberMessageTemplateDto ToTemplateDto(MemberMessageTemplate source)
|
||||
{
|
||||
return new MemberMessageTemplateDto
|
||||
{
|
||||
TemplateId = source.Id,
|
||||
Name = source.Name,
|
||||
Category = ToTemplateCategoryText(source.Category),
|
||||
Content = source.Content,
|
||||
UsageCount = source.UsageCount,
|
||||
LastUsedAt = source.LastUsedAt
|
||||
};
|
||||
}
|
||||
|
||||
internal static MemberMessageReachRecipientDto ToRecipientDto(MemberReachRecipient source)
|
||||
{
|
||||
return new MemberMessageReachRecipientDto
|
||||
{
|
||||
MemberId = source.MemberId,
|
||||
Channel = ToChannelText(source.Channel),
|
||||
Status = ToRecipientStatusText(source.Status),
|
||||
Mobile = source.Mobile,
|
||||
OpenId = source.OpenId,
|
||||
SentAt = source.SentAt,
|
||||
ReadAt = source.ReadAt,
|
||||
ConvertedAt = source.ConvertedAt,
|
||||
ErrorMessage = source.ErrorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息模块配置。
|
||||
/// </summary>
|
||||
public sealed class MemberMessagingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 会员消息短信场景码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SmsScene { get; set; } = "member_message";
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序发送配置。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public MemberMessagingWeChatMiniOptions WeChatMini { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序消息发送配置。
|
||||
/// </summary>
|
||||
public sealed class MemberMessagingWeChatMiniOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 小程序 AppId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序 AppSecret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅消息模板 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string SubscribeTemplateId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序跳转页面路径。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string PagePath { get; set; } = "pages/index/index";
|
||||
|
||||
/// <summary>
|
||||
/// 标题字段键名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string TitleDataKey { get; set; } = "thing1";
|
||||
|
||||
/// <summary>
|
||||
/// 内容字段键名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ContentDataKey { get; set; } = "thing2";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达应用服务。
|
||||
/// </summary>
|
||||
public interface IMemberMessageReachAppService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取月度统计。
|
||||
/// </summary>
|
||||
Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询消息。
|
||||
/// </summary>
|
||||
Task<MemberMessageReachListResultDto> SearchMessagesAsync(
|
||||
long tenantId,
|
||||
SearchMemberMessageInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取消息详情。
|
||||
/// </summary>
|
||||
Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取消息调度元信息。
|
||||
/// </summary>
|
||||
Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存消息草稿或发送任务。
|
||||
/// </summary>
|
||||
Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
|
||||
long tenantId,
|
||||
SaveMemberMessageInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 绑定消息对应的 Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
Task BindDispatchJobAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
string? hangfireJobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息并返回原任务 ID。
|
||||
/// </summary>
|
||||
Task<string?> DeleteMessageAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 估算目标人群数量。
|
||||
/// </summary>
|
||||
Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
|
||||
long tenantId,
|
||||
MemberMessageAudienceEstimateInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询模板。
|
||||
/// </summary>
|
||||
Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
SearchMemberMessageTemplateInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取模板详情。
|
||||
/// </summary>
|
||||
Task<MemberMessageTemplateDto?> GetTemplateAsync(
|
||||
long tenantId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存模板。
|
||||
/// </summary>
|
||||
Task<MemberMessageTemplateDto> SaveTemplateAsync(
|
||||
long tenantId,
|
||||
SaveMemberMessageTemplateInput input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板。
|
||||
/// </summary>
|
||||
Task DeleteTemplateAsync(
|
||||
long tenantId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 执行消息发送。
|
||||
/// </summary>
|
||||
Task ExecuteDispatchAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序订阅消息发送器。
|
||||
/// </summary>
|
||||
public interface IMemberMessageWeChatSender
|
||||
{
|
||||
/// <summary>
|
||||
/// 发送微信订阅消息。
|
||||
/// </summary>
|
||||
Task SendAsync(
|
||||
string openId,
|
||||
string title,
|
||||
string content,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||
using TakeoutSaaS.Module.Sms.Models;
|
||||
using TakeoutSaaS.Module.Sms.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达应用服务实现。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageReachAppService(
|
||||
IMemberMessageReachRepository memberMessageReachRepository,
|
||||
IMemberRepository memberRepository,
|
||||
IMiniUserRepository miniUserRepository,
|
||||
ISmsSenderResolver smsSenderResolver,
|
||||
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
|
||||
IOptionsMonitor<MemberMessagingOptions> memberMessagingOptionsMonitor,
|
||||
IMemberMessageWeChatSender memberMessageWeChatSender,
|
||||
ILogger<MemberMessageReachAppService> logger)
|
||||
: IMemberMessageReachAppService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> AudienceTagAliasMap = BuildAudienceTagAliasMap();
|
||||
private static readonly IReadOnlyDictionary<string, string> AudienceTagDisplayMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["highfrequency"] = "高频客户",
|
||||
["newcustomer"] = "新客",
|
||||
["dormant"] = "沉睡客户",
|
||||
["lost"] = "流失客户",
|
||||
["lunchregular"] = "午餐常客",
|
||||
["highspend"] = "大额消费"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlySet<string> EmptyTagSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 校验租户上下文。
|
||||
EnsureTenantId(tenantId);
|
||||
|
||||
// 2. 读取当月统计快照并计算转化率。
|
||||
var now = DateTime.UtcNow;
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var monthEnd = monthStart.AddMonths(1);
|
||||
var snapshot = await memberMessageReachRepository.GetMonthlyStatsAsync(tenantId, monthStart, monthEnd, cancellationToken);
|
||||
var openRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ReadRecipientCount, snapshot.SentRecipientCount);
|
||||
var conversionRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ConvertedRecipientCount, snapshot.SentRecipientCount);
|
||||
|
||||
// 3. 返回页面统计 DTO。
|
||||
return new MemberMessageReachStatsDto
|
||||
{
|
||||
MonthlySentCount = snapshot.SentMessageCount,
|
||||
ReachMemberCount = snapshot.ReachMemberCount,
|
||||
OpenRate = openRate,
|
||||
ConversionRate = conversionRate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageReachListResultDto> SearchMessagesAsync(
|
||||
long tenantId,
|
||||
SearchMemberMessageInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 校验租户与查询参数。
|
||||
EnsureTenantId(tenantId);
|
||||
var page = input.Page <= 0 ? 1 : input.Page;
|
||||
var pageSize = Math.Clamp(input.PageSize, 1, 100);
|
||||
var status = MemberMessageReachMapping.TryParseStatus(input.Status);
|
||||
var channel = MemberMessageReachMapping.TryParseChannel(input.Channel);
|
||||
|
||||
// 2. 调用仓储分页查询。
|
||||
var (items, total) = await memberMessageReachRepository.SearchMessagesAsync(
|
||||
tenantId,
|
||||
status,
|
||||
channel,
|
||||
input.Keyword,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 映射分页结果。
|
||||
return new MemberMessageReachListResultDto
|
||||
{
|
||||
Items = items.Select(ToMessageListItem).ToList(),
|
||||
TotalCount = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询主消息记录。
|
||||
EnsureTenantId(tenantId);
|
||||
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 查询并映射收件明细。
|
||||
var recipients = await memberMessageReachRepository.GetRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson);
|
||||
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
|
||||
|
||||
// 3. 返回详情数据。
|
||||
return new MemberMessageReachDetailDto
|
||||
{
|
||||
MessageId = message.Id,
|
||||
TemplateId = message.TemplateId,
|
||||
Title = message.Title,
|
||||
Content = message.Content,
|
||||
Channels = channels,
|
||||
AudienceType = MemberMessageReachMapping.ToAudienceTypeText(message.AudienceType),
|
||||
AudienceTags = audienceTags,
|
||||
AudienceText = BuildAudienceText(message.AudienceType, audienceTags, message.EstimatedReachCount),
|
||||
EstimatedReachCount = message.EstimatedReachCount,
|
||||
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(message.ScheduleType),
|
||||
ScheduledAt = message.ScheduledAt,
|
||||
Status = MemberMessageReachMapping.ToStatusText(message.Status),
|
||||
SentAt = message.SentAt,
|
||||
SentCount = message.SentCount,
|
||||
ReadCount = message.ReadCount,
|
||||
ConvertedCount = message.ConvertedCount,
|
||||
OpenRate = MemberMessageReachMapping.ResolveRatePercent(message.ReadCount, message.SentCount),
|
||||
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(message.ConvertedCount, message.SentCount),
|
||||
LastError = message.LastError,
|
||||
Recipients = recipients.Select(MemberMessageReachMapping.ToRecipientDto).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询消息并返回调度元数据。
|
||||
EnsureTenantId(tenantId);
|
||||
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ToDispatchMeta(message);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
|
||||
long tenantId,
|
||||
SaveMemberMessageInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 入参校验与基础归一化。
|
||||
EnsureTenantId(tenantId);
|
||||
var submitAction = NormalizeSubmitAction(input.SubmitAction);
|
||||
var title = NormalizeRequiredText(input.Title, 128, nameof(input.Title));
|
||||
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
|
||||
var channels = MemberMessageReachMapping.NormalizeChannels(input.Channels);
|
||||
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
|
||||
var audienceTags = MemberMessageReachMapping.NormalizeTags(input.AudienceTags);
|
||||
var scheduleType = MemberMessageReachMapping.ParseScheduleType(input.ScheduleType);
|
||||
var scheduledAt = NormalizeScheduledAt(scheduleType, submitAction, input.ScheduledAt);
|
||||
if (audienceType == MemberMessageAudienceType.Tags && audienceTags.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
|
||||
}
|
||||
|
||||
// 2. 估算目标人群并读取/创建消息实体。
|
||||
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, audienceTags, cancellationToken);
|
||||
var estimatedReachCount = profiles.Count;
|
||||
var isNew = !input.MessageId.HasValue;
|
||||
MemberReachMessage message;
|
||||
if (isNew)
|
||||
{
|
||||
message = new MemberReachMessage
|
||||
{
|
||||
TenantId = tenantId
|
||||
};
|
||||
await memberMessageReachRepository.AddMessageAsync(message, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, input.MessageId!.Value, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||
EnsureMessageEditable(message);
|
||||
}
|
||||
|
||||
// 3. 覆盖消息字段并重置发送态字段。
|
||||
message.StoreId = input.StoreId;
|
||||
message.TemplateId = input.TemplateId;
|
||||
message.Title = title;
|
||||
message.Content = content;
|
||||
message.ChannelsJson = MemberMessageReachMapping.SerializeStringArray(channels);
|
||||
message.AudienceType = audienceType;
|
||||
message.AudienceTagsJson = MemberMessageReachMapping.SerializeStringArray(audienceTags);
|
||||
message.EstimatedReachCount = estimatedReachCount;
|
||||
message.ScheduleType = scheduleType;
|
||||
message.ScheduledAt = scheduleType == MemberMessageScheduleType.Scheduled ? scheduledAt : null;
|
||||
message.Status = submitAction == "send" ? MemberMessageStatus.Pending : MemberMessageStatus.Draft;
|
||||
message.HangfireJobId = null;
|
||||
message.SentAt = null;
|
||||
message.SentCount = 0;
|
||||
message.ReadCount = 0;
|
||||
message.ConvertedCount = 0;
|
||||
message.LastError = null;
|
||||
|
||||
// 4. 编辑场景清理旧收件记录,确保再次发送时数据一致。
|
||||
if (!isNew)
|
||||
{
|
||||
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, message.Id, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 持久化并返回调度信息。
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
return ToDispatchMeta(message);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task BindDispatchJobAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
string? hangfireJobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询并绑定任务 ID。
|
||||
EnsureTenantId(tenantId);
|
||||
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||
message.HangfireJobId = string.IsNullOrWhiteSpace(hangfireJobId)
|
||||
? null
|
||||
: Truncate(hangfireJobId.Trim(), 64);
|
||||
|
||||
// 2. 保存更新结果。
|
||||
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> DeleteMessageAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询待删除消息并校验状态。
|
||||
EnsureTenantId(tenantId);
|
||||
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||
if (message.Status == MemberMessageStatus.Sending)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许删除");
|
||||
}
|
||||
|
||||
// 2. 先删收件明细,再删主记录。
|
||||
var oldHangfireJobId = message.HangfireJobId;
|
||||
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||
await memberMessageReachRepository.DeleteMessageAsync(message, cancellationToken);
|
||||
|
||||
// 3. 持久化删除。
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
return oldHangfireJobId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
|
||||
long tenantId,
|
||||
MemberMessageAudienceEstimateInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 解析目标规则。
|
||||
EnsureTenantId(tenantId);
|
||||
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
|
||||
var tags = MemberMessageReachMapping.NormalizeTags(input.Tags);
|
||||
if (audienceType == MemberMessageAudienceType.Tags && tags.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
|
||||
}
|
||||
|
||||
// 2. 计算可触达人数。
|
||||
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, tags, cancellationToken);
|
||||
return new MemberMessageAudienceEstimateDto
|
||||
{
|
||||
ReachCount = profiles.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
SearchMemberMessageTemplateInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 归一化分页参数。
|
||||
EnsureTenantId(tenantId);
|
||||
var page = input.Page <= 0 ? 1 : input.Page;
|
||||
var pageSize = Math.Clamp(input.PageSize, 1, 100);
|
||||
var category = MemberMessageReachMapping.TryParseTemplateCategory(input.Category);
|
||||
|
||||
// 2. 分页查询模板。
|
||||
var (items, total) = await memberMessageReachRepository.SearchTemplatesAsync(
|
||||
tenantId,
|
||||
category,
|
||||
input.Keyword,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 映射并返回。
|
||||
return new MemberMessageTemplateListResultDto
|
||||
{
|
||||
Items = items.Select(MemberMessageReachMapping.ToTemplateDto).ToList(),
|
||||
TotalCount = total,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageTemplateDto?> GetTemplateAsync(
|
||||
long tenantId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板详情。
|
||||
EnsureTenantId(tenantId);
|
||||
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken);
|
||||
return template is null ? null : MemberMessageReachMapping.ToTemplateDto(template);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageTemplateDto> SaveTemplateAsync(
|
||||
long tenantId,
|
||||
SaveMemberMessageTemplateInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 校验并归一化模板入参。
|
||||
EnsureTenantId(tenantId);
|
||||
var name = NormalizeRequiredText(input.Name, 64, nameof(input.Name));
|
||||
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
|
||||
var category = MemberMessageReachMapping.ParseTemplateCategory(input.Category);
|
||||
|
||||
// 2. 校验同租户模板名称唯一。
|
||||
var existingTemplateByName = await memberMessageReachRepository.FindTemplateByNameAsync(tenantId, name, cancellationToken);
|
||||
if (existingTemplateByName is not null && existingTemplateByName.Id != input.TemplateId.GetValueOrDefault())
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "模板名称已存在");
|
||||
}
|
||||
|
||||
// 3. 查询或创建模板实体。
|
||||
var isNew = !input.TemplateId.HasValue;
|
||||
MemberMessageTemplate template;
|
||||
if (isNew)
|
||||
{
|
||||
template = new MemberMessageTemplate
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UsageCount = 0
|
||||
};
|
||||
await memberMessageReachRepository.AddTemplateAsync(template, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, input.TemplateId!.Value, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
|
||||
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||
}
|
||||
|
||||
// 4. 赋值并保存模板。
|
||||
template.Name = name;
|
||||
template.Content = content;
|
||||
template.Category = category;
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
return MemberMessageReachMapping.ToTemplateDto(template);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateAsync(
|
||||
long tenantId,
|
||||
long templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板并执行删除。
|
||||
EnsureTenantId(tenantId);
|
||||
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
|
||||
await memberMessageReachRepository.DeleteTemplateAsync(template, cancellationToken);
|
||||
|
||||
// 2. 持久化删除。
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteDispatchAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询消息并校验状态。
|
||||
EnsureTenantId(tenantId);
|
||||
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
logger.LogWarning("消息发送任务未找到消息记录,TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Status == MemberMessageStatus.Sending || message.Status == MemberMessageStatus.Sent)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"消息发送任务跳过,状态无需发送,TenantId={TenantId} MessageId={MessageId} Status={Status}",
|
||||
tenantId,
|
||||
messageId,
|
||||
message.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Status != MemberMessageStatus.Pending)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"消息发送任务跳过,状态非待发送,TenantId={TenantId} MessageId={MessageId} Status={Status}",
|
||||
tenantId,
|
||||
messageId,
|
||||
message.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 将消息状态推进为发送中并清理旧任务标识。
|
||||
message.Status = MemberMessageStatus.Sending;
|
||||
message.HangfireJobId = null;
|
||||
message.LastError = null;
|
||||
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 解析目标人群与渠道配置。
|
||||
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
|
||||
var audienceProfiles = await ResolveAudienceProfilesAsync(tenantId, message.AudienceType, audienceTags, cancellationToken);
|
||||
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson)
|
||||
.Select(MemberMessageReachMapping.ParseChannel)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "消息渠道为空,无法执行发送");
|
||||
}
|
||||
|
||||
// 4. 清理旧收件明细并准备渠道所需映射。
|
||||
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||
var openIdMap = await ResolveMiniUserOpenIdMapAsync(tenantId, audienceProfiles, cancellationToken);
|
||||
|
||||
// 5. 按“会员 x 渠道”创建发送明细。
|
||||
var recipients = new List<MemberReachRecipient>(Math.Max(1, audienceProfiles.Count * channels.Count));
|
||||
var errorMessages = new List<string>();
|
||||
foreach (var profile in audienceProfiles)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
var recipient = new MemberReachRecipient
|
||||
{
|
||||
TenantId = tenantId,
|
||||
MessageId = messageId,
|
||||
MemberId = profile.Id,
|
||||
Channel = channel,
|
||||
Status = MemberMessageRecipientStatus.Pending,
|
||||
Mobile = string.IsNullOrWhiteSpace(profile.Mobile) ? null : profile.Mobile.Trim()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// 5.1 按渠道执行真实发送。
|
||||
switch (channel)
|
||||
{
|
||||
case MemberMessageChannel.InApp:
|
||||
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||
recipient.SentAt = DateTime.UtcNow;
|
||||
break;
|
||||
|
||||
case MemberMessageChannel.Sms:
|
||||
{
|
||||
var phone = NormalizePhoneNumber(profile.Mobile);
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "会员手机号为空");
|
||||
}
|
||||
|
||||
recipient.Mobile = phone;
|
||||
await SendSmsAsync(phone, message.Title, message.Content, cancellationToken);
|
||||
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||
recipient.SentAt = DateTime.UtcNow;
|
||||
break;
|
||||
}
|
||||
|
||||
case MemberMessageChannel.WeChatMini:
|
||||
{
|
||||
if (!openIdMap.TryGetValue(profile.UserId, out var openId) || string.IsNullOrWhiteSpace(openId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "会员未绑定小程序 OpenId");
|
||||
}
|
||||
|
||||
recipient.OpenId = openId;
|
||||
await memberMessageWeChatSender.SendAsync(openId, message.Title, message.Content, cancellationToken);
|
||||
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||
recipient.SentAt = DateTime.UtcNow;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "不支持的消息渠道");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5.2 单个收件人发送失败不影响整体流程,保留失败明细。
|
||||
recipient.Status = MemberMessageRecipientStatus.Failed;
|
||||
recipient.ErrorMessage = Truncate(CleanupErrorMessage(ex.Message), 512);
|
||||
errorMessages.Add($"会员{profile.Id}-{MemberMessageReachMapping.ToChannelText(channel)}:{recipient.ErrorMessage}");
|
||||
}
|
||||
|
||||
recipients.Add(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 写入收件明细并回填消息统计。
|
||||
await memberMessageReachRepository.AddRecipientsAsync(recipients, cancellationToken);
|
||||
message.EstimatedReachCount = audienceProfiles.Count;
|
||||
message.SentCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
|
||||
message.ReadCount = recipients.Count(item => item.ReadAt.HasValue);
|
||||
message.ConvertedCount = recipients.Count(item => item.ConvertedAt.HasValue);
|
||||
message.SentAt = DateTime.UtcNow;
|
||||
message.Status = message.SentCount > 0 ? MemberMessageStatus.Sent : MemberMessageStatus.Failed;
|
||||
message.LastError = BuildErrorSummary(errorMessages);
|
||||
|
||||
// 7. 若使用模板发送,更新模板使用次数。
|
||||
if (message.TemplateId.HasValue)
|
||||
{
|
||||
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, message.TemplateId.Value, cancellationToken);
|
||||
if (template is not null)
|
||||
{
|
||||
template.UsageCount += 1;
|
||||
template.LastUsedAt = DateTime.UtcNow;
|
||||
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 保存最终状态。
|
||||
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 9. 全局异常兜底写失败态,并保留错误摘要。
|
||||
logger.LogError(ex, "执行会员消息发送失败,TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
|
||||
message.Status = MemberMessageStatus.Failed;
|
||||
message.SentAt = DateTime.UtcNow;
|
||||
message.SentCount = 0;
|
||||
message.ReadCount = 0;
|
||||
message.ConvertedCount = 0;
|
||||
message.LastError = Truncate(CleanupErrorMessage(ex.Message), 1024);
|
||||
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendSmsAsync(
|
||||
string phoneNumber,
|
||||
string title,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取短信模板配置并解析场景模板编码。
|
||||
var smsOptions = smsOptionsMonitor.CurrentValue;
|
||||
var messageOptions = memberMessagingOptionsMonitor.CurrentValue;
|
||||
var smsScene = string.IsNullOrWhiteSpace(messageOptions.SmsScene)
|
||||
? "member_message"
|
||||
: messageOptions.SmsScene.Trim();
|
||||
if (!smsOptions.SceneTemplates.TryGetValue(smsScene, out var templateCode) ||
|
||||
string.IsNullOrWhiteSpace(templateCode))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"未配置短信模板场景:{smsScene}");
|
||||
}
|
||||
|
||||
// 2. 组装变量并调用短信通道发送。
|
||||
var sender = smsSenderResolver.Resolve();
|
||||
var variables = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["title"] = Truncate(title.Trim(), 20),
|
||||
["content"] = Truncate(content.Trim(), 64)
|
||||
};
|
||||
var request = new SmsSendRequest(phoneNumber, templateCode, variables, smsOptions.DefaultSignName);
|
||||
var result = await sender.SendAsync(request, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{result.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MemberProfile>> ResolveAudienceProfilesAsync(
|
||||
long tenantId,
|
||||
MemberMessageAudienceType audienceType,
|
||||
IReadOnlyList<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户全部会员。
|
||||
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 全量人群直接返回。
|
||||
if (audienceType == MemberMessageAudienceType.All)
|
||||
{
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// 3. 标签人群解析标签规则并构建会员标签映射。
|
||||
var normalizedInputTags = tags
|
||||
.Select(ToCanonicalAudienceTag)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalizedInputTags.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var profileIds = profiles.Select(profile => profile.Id).ToList();
|
||||
var profileTags = await memberRepository.GetProfileTagsByMemberIdsAsync(tenantId, profileIds, cancellationToken);
|
||||
var profileTagLookup = profileTags
|
||||
.GroupBy(item => item.MemberProfileId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.Select(tag => ToCanonicalAudienceTag(tag.TagName))
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// 4. 应用固定规则筛选目标会员。
|
||||
var now = DateTime.UtcNow;
|
||||
var selected = profiles
|
||||
.Where(profile =>
|
||||
{
|
||||
var tagsOfProfile = profileTagLookup.TryGetValue(profile.Id, out var set)
|
||||
? set
|
||||
: EmptyTagSet;
|
||||
|
||||
return normalizedInputTags.Any(tag => MatchesAudienceTag(profile, tagsOfProfile, tag, now));
|
||||
})
|
||||
.ToList();
|
||||
return selected;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, string>> ResolveMiniUserOpenIdMapAsync(
|
||||
long tenantId,
|
||||
IReadOnlyList<MemberProfile> profiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 收集会员关联的小程序用户标识。
|
||||
var miniUserIds = profiles
|
||||
.Select(profile => profile.UserId)
|
||||
.Where(userId => userId > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (miniUserIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 批量查询并映射 OpenId。
|
||||
var miniUsers = await miniUserRepository.GetByIdsAsync(miniUserIds, tenantId, cancellationToken);
|
||||
return miniUsers
|
||||
.Where(user => !string.IsNullOrWhiteSpace(user.OpenId))
|
||||
.ToDictionary(user => user.Id, user => user.OpenId, comparer: EqualityComparer<long>.Default);
|
||||
}
|
||||
|
||||
private static MemberMessageReachListItemDto ToMessageListItem(MemberReachMessage source)
|
||||
{
|
||||
var channels = MemberMessageReachMapping.DeserializeStringArray(source.ChannelsJson);
|
||||
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(source.AudienceTagsJson);
|
||||
return new MemberMessageReachListItemDto
|
||||
{
|
||||
MessageId = source.Id,
|
||||
Title = source.Title,
|
||||
Channels = channels,
|
||||
AudienceText = BuildAudienceText(source.AudienceType, audienceTags, source.EstimatedReachCount),
|
||||
EstimatedReachCount = source.EstimatedReachCount,
|
||||
Status = MemberMessageReachMapping.ToStatusText(source.Status),
|
||||
SentAt = source.SentAt,
|
||||
ScheduledAt = source.ScheduledAt,
|
||||
OpenRate = MemberMessageReachMapping.ResolveRatePercent(source.ReadCount, source.SentCount),
|
||||
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(source.ConvertedCount, source.SentCount)
|
||||
};
|
||||
}
|
||||
|
||||
private static MemberMessageDispatchMetaDto ToDispatchMeta(MemberReachMessage source)
|
||||
{
|
||||
return new MemberMessageDispatchMetaDto
|
||||
{
|
||||
MessageId = source.Id,
|
||||
Status = MemberMessageReachMapping.ToStatusText(source.Status),
|
||||
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(source.ScheduleType),
|
||||
ScheduledAt = source.ScheduledAt,
|
||||
HangfireJobId = source.HangfireJobId
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureTenantId(long tenantId)
|
||||
{
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "tenantId 非法");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureMessageEditable(MemberReachMessage message)
|
||||
{
|
||||
if (message.Status == MemberMessageStatus.Sending)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许编辑");
|
||||
}
|
||||
|
||||
if (message.Status == MemberMessageStatus.Sent)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "已发送消息不允许编辑");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSubmitAction(string? submitAction)
|
||||
{
|
||||
var action = (submitAction ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return action switch
|
||||
{
|
||||
"draft" => "draft",
|
||||
"send" => "send",
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "submitAction 非法")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeRequiredText(string? value, int maxLength, string fieldName)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static DateTime? NormalizeScheduledAt(
|
||||
MemberMessageScheduleType scheduleType,
|
||||
string submitAction,
|
||||
DateTime? scheduledAt)
|
||||
{
|
||||
if (scheduleType == MemberMessageScheduleType.Immediate)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (submitAction == "draft")
|
||||
{
|
||||
return scheduledAt?.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (!scheduledAt.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "定时发送必须设置 scheduledAt");
|
||||
}
|
||||
|
||||
var utcTime = scheduledAt.Value.ToUniversalTime();
|
||||
if (utcTime <= DateTime.UtcNow.AddMinutes(1))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "定时发送时间必须晚于当前时间 1 分钟");
|
||||
}
|
||||
|
||||
return utcTime;
|
||||
}
|
||||
|
||||
private static bool MatchesAudienceTag(
|
||||
MemberProfile profile,
|
||||
IReadOnlySet<string> profileTags,
|
||||
string targetTag,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var hasProfileTag = profileTags.Contains(targetTag);
|
||||
|
||||
return targetTag switch
|
||||
{
|
||||
"newcustomer" => hasProfileTag || profile.JoinedAt >= nowUtc.AddDays(-30),
|
||||
"dormant" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-90) && profile.StoredBalance <= 0m && profile.PointsBalance <= 0),
|
||||
"lost" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-180) && profile.Status != MemberStatus.Active),
|
||||
"highspend" => hasProfileTag || profile.StoredRechargeBalance >= 1000m || profile.StoredBalance >= 1000m,
|
||||
_ => hasProfileTag
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildAudienceText(
|
||||
MemberMessageAudienceType audienceType,
|
||||
IReadOnlyList<string> tags,
|
||||
int estimatedReachCount)
|
||||
{
|
||||
if (audienceType == MemberMessageAudienceType.All)
|
||||
{
|
||||
return "全部会员";
|
||||
}
|
||||
|
||||
var displayTags = tags
|
||||
.Select(ResolveAudienceDisplayName)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (displayTags.Count == 0)
|
||||
{
|
||||
return $"标签人群({estimatedReachCount}人)";
|
||||
}
|
||||
|
||||
return $"{string.Join("、", displayTags)}({estimatedReachCount}人)";
|
||||
}
|
||||
|
||||
private static string ResolveAudienceDisplayName(string sourceTag)
|
||||
{
|
||||
var canonical = ToCanonicalAudienceTag(sourceTag);
|
||||
return AudienceTagDisplayMap.TryGetValue(canonical, out var displayName)
|
||||
? displayName
|
||||
: (sourceTag ?? string.Empty).Trim();
|
||||
}
|
||||
|
||||
private static string ToCanonicalAudienceTag(string? sourceTag)
|
||||
{
|
||||
var normalized = NormalizeAudienceTag(sourceTag);
|
||||
return AudienceTagAliasMap.TryGetValue(normalized, out var canonical)
|
||||
? canonical
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeAudienceTag(string? sourceTag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceTag))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = sourceTag.Trim().ToLowerInvariant();
|
||||
var filtered = trimmed
|
||||
.Where(ch => ch is not (' ' or '-' or '_'))
|
||||
.ToArray();
|
||||
return new string(filtered);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildAudienceTagAliasMap()
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["highfrequency"] = "highfrequency",
|
||||
["高频客户"] = "highfrequency",
|
||||
["高频用户"] = "highfrequency",
|
||||
["高频"] = "highfrequency",
|
||||
|
||||
["newcustomer"] = "newcustomer",
|
||||
["新客"] = "newcustomer",
|
||||
["新客户"] = "newcustomer",
|
||||
|
||||
["dormant"] = "dormant",
|
||||
["沉睡客户"] = "dormant",
|
||||
["沉睡用户"] = "dormant",
|
||||
|
||||
["lost"] = "lost",
|
||||
["流失客户"] = "lost",
|
||||
["流失用户"] = "lost",
|
||||
|
||||
["lunchregular"] = "lunchregular",
|
||||
["午餐常客"] = "lunchregular",
|
||||
|
||||
["highspend"] = "highspend",
|
||||
["大额消费"] = "highspend",
|
||||
["高消费"] = "highspend"
|
||||
};
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string? BuildErrorSummary(IReadOnlyList<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = string.Join(" | ", errors.Take(5));
|
||||
return Truncate(content, 1024);
|
||||
}
|
||||
|
||||
private static string CleanupErrorMessage(string? message)
|
||||
{
|
||||
return (message ?? string.Empty)
|
||||
.Replace('\r', ' ')
|
||||
.Replace('\n', ' ')
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizePhoneNumber(string? mobile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mobile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = mobile.Trim();
|
||||
return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}";
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length <= maxLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序订阅消息发送器。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageWeChatSender(
|
||||
HttpClient httpClient,
|
||||
IDistributedCache cache,
|
||||
IOptionsMonitor<MemberMessagingOptions> optionsMonitor)
|
||||
: IMemberMessageWeChatSender
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(
|
||||
string openId,
|
||||
string title,
|
||||
string content,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(openId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "openId 不能为空");
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.WeChatMini;
|
||||
var accessToken = await ResolveAccessTokenAsync(options, cancellationToken);
|
||||
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["touser"] = openId.Trim(),
|
||||
["template_id"] = options.SubscribeTemplateId,
|
||||
["page"] = options.PagePath,
|
||||
["data"] = new Dictionary<string, object?>
|
||||
{
|
||||
[options.TitleDataKey] = new { value = Truncate(title, 20) },
|
||||
[options.ContentDataKey] = new { value = Truncate(content, 20) }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync(
|
||||
$"cgi-bin/message/subscribe/send?access_token={Uri.EscapeDataString(accessToken)}",
|
||||
requestBody,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatErrorPayload>(JsonOptions, cancellationToken);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, "微信发送失败:响应为空");
|
||||
}
|
||||
|
||||
if (payload.ErrorCode != 0)
|
||||
{
|
||||
throw new BusinessException(
|
||||
ErrorCodes.InternalServerError,
|
||||
$"微信发送失败:{payload.ErrorCode} {payload.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveAccessTokenAsync(
|
||||
MemberMessagingWeChatMiniOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"member-message:wechat:access-token:{options.AppId}";
|
||||
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var response = await httpClient.GetAsync(
|
||||
$"cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(options.AppId)}&secret={Uri.EscapeDataString(options.AppSecret)}",
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatTokenPayload>(JsonOptions, cancellationToken);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败:响应为空");
|
||||
}
|
||||
|
||||
if (payload.ErrorCode != 0)
|
||||
{
|
||||
throw new BusinessException(
|
||||
ErrorCodes.InternalServerError,
|
||||
$"微信 access_token 获取失败:{payload.ErrorCode} {payload.ErrorMessage}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.AccessToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败:token 为空");
|
||||
}
|
||||
|
||||
var ttlSeconds = payload.ExpiresIn > 120 ? payload.ExpiresIn - 120 : payload.ExpiresIn;
|
||||
await cache.SetStringAsync(
|
||||
cacheKey,
|
||||
payload.AccessToken,
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, ttlSeconds))
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return payload.AccessToken;
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length <= maxLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized[..maxLength];
|
||||
}
|
||||
|
||||
private sealed class WeChatTokenPayload
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
private sealed class WeChatErrorPayload
|
||||
{
|
||||
[JsonPropertyName("errcode")]
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}\"";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -60,6 +60,16 @@ public sealed record SubmitTenantVerificationCommand : IRequest<TenantVerificati
|
||||
/// </summary>
|
||||
public string? BankName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信商户号。
|
||||
/// </summary>
|
||||
public string? WeChatMerchantNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝 PID。
|
||||
/// </summary>
|
||||
public string? AlipayPid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 其他补充资料 JSON。
|
||||
/// </summary>
|
||||
|
||||
@@ -71,6 +71,16 @@ public sealed class TenantVerificationDto
|
||||
/// </summary>
|
||||
public string? BankName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信商户号。
|
||||
/// </summary>
|
||||
public string? WeChatMerchantNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝 PID。
|
||||
/// </summary>
|
||||
public string? AlipayPid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 附加资料(JSON)。
|
||||
/// </summary>
|
||||
|
||||
@@ -54,6 +54,8 @@ public sealed class SubmitTenantVerificationCommandHandler(
|
||||
profile.BankAccountName = request.BankAccountName;
|
||||
profile.BankAccountNumber = request.BankAccountNumber;
|
||||
profile.BankName = request.BankName;
|
||||
profile.WeChatMerchantNo = request.WeChatMerchantNo;
|
||||
profile.AlipayPid = request.AlipayPid;
|
||||
profile.AdditionalDataJson = request.AdditionalDataJson;
|
||||
profile.Status = TenantVerificationStatus.Pending;
|
||||
profile.SubmittedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -31,6 +31,8 @@ internal static class TenantMapping
|
||||
BankAccountName = profile.BankAccountName,
|
||||
BankAccountNumber = profile.BankAccountNumber,
|
||||
BankName = profile.BankName,
|
||||
WeChatMerchantNo = profile.WeChatMerchantNo,
|
||||
AlipayPid = profile.AlipayPid,
|
||||
AdditionalDataJson = profile.AdditionalDataJson,
|
||||
SubmittedAt = profile.SubmittedAt,
|
||||
ReviewRemarks = profile.ReviewRemarks,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询汇总行。
|
||||
/// </summary>
|
||||
public sealed record FinanceSettlementListItemSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 到账日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public required DateTime ArrivedDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public required PaymentMethod PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public required int TransactionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public required decimal ArrivedAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询明细行。
|
||||
/// </summary>
|
||||
public sealed record FinanceSettlementDetailItemSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public required string OrderNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public required decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(UTC)。
|
||||
/// </summary>
|
||||
public required DateTime PaidAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账查询分页快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceSettlementPageSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public required IReadOnlyList<FinanceSettlementListItemSnapshot> Items { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账概览统计快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceSettlementStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日到账。
|
||||
/// </summary>
|
||||
public required decimal TodayArrivedAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日到账。
|
||||
/// </summary>
|
||||
public required decimal YesterdayArrivedAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月到账。
|
||||
/// </summary>
|
||||
public required decimal CurrentMonthArrivedAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月交易笔数。
|
||||
/// </summary>
|
||||
public required int CurrentMonthTransactionCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到账账户信息快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceSettlementAccountSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 银行名称。
|
||||
/// </summary>
|
||||
public required string BankName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户名。
|
||||
/// </summary>
|
||||
public required string BankAccountName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
public required string BankAccountNoMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信商户号(脱敏)。
|
||||
/// </summary>
|
||||
public required string WechatMerchantNoMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝 PID(脱敏)。
|
||||
/// </summary>
|
||||
public required string AlipayPidMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期文案。
|
||||
/// </summary>
|
||||
public required string SettlementPeriodText { get; init; }
|
||||
}
|
||||
@@ -63,4 +63,55 @@ public interface IFinanceTransactionRepository
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账概览统计。
|
||||
/// </summary>
|
||||
Task<FinanceSettlementStatsSnapshot> GetSettlementStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime currentUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账账户信息。
|
||||
/// </summary>
|
||||
Task<FinanceSettlementAccountSnapshot?> GetSettlementAccountAsync(
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账汇总分页。
|
||||
/// </summary>
|
||||
Task<FinanceSettlementPageSnapshot> SearchSettlementPageAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
PaymentMethod? paymentMethod,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账明细。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> GetSettlementDetailsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime arrivedDate,
|
||||
PaymentMethod paymentMethod,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询到账导出数据。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> ListSettlementForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
PaymentMethod? paymentMethod,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ public interface IMiniUserRepository
|
||||
/// <returns>小程序用户,如果不存在则返回 null</returns>
|
||||
Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按用户标识集合批量查询小程序用户。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
|
||||
IReadOnlyCollection<long> ids,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息模板。
|
||||
/// </summary>
|
||||
public sealed class MemberMessageTemplate : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板分类。
|
||||
/// </summary>
|
||||
public MemberMessageTemplateCategory Category { get; set; } = MemberMessageTemplateCategory.Notice;
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用次数。
|
||||
/// </summary>
|
||||
public int UsageCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近使用时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城兑换商品。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallProduct : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片地址。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型。
|
||||
/// </summary>
|
||||
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.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>
|
||||
/// 实物领取方式。
|
||||
/// </summary>
|
||||
public MemberPointMallPickupMethod? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(纯积分/积分+现金)。
|
||||
/// </summary>
|
||||
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
|
||||
|
||||
/// <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>
|
||||
/// 每人限兑次数(null 表示不限)。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账通知渠道(JSON 数组)。
|
||||
/// </summary>
|
||||
public string NotifyChannelsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 上下架状态。
|
||||
/// </summary>
|
||||
public MemberPointMallProductStatus Status { get; set; } = MemberPointMallProductStatus.Enabled;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城兑换记录。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecord : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联积分商品 ID。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; set; }
|
||||
|
||||
/// <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 string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型快照。
|
||||
/// </summary>
|
||||
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式快照。
|
||||
/// </summary>
|
||||
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录状态。
|
||||
/// </summary>
|
||||
public MemberPointMallRecordStatus Status { get; set; } = MemberPointMallRecordStatus.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; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式。
|
||||
/// </summary>
|
||||
public MemberPointMallVerifyMethod? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人用户标识。
|
||||
/// </summary>
|
||||
public long? VerifiedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城规则配置。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRule : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 积分有效期模式。
|
||||
/// </summary>
|
||||
public MemberPointMallExpiryMode ExpiryMode { get; set; } = MemberPointMallExpiryMode.YearlyClear;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达主记录。
|
||||
/// </summary>
|
||||
public sealed class MemberReachMessage : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识(可空,空表示当前商户全部可见门店)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板标识(可空)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消息正文。
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道数组 JSON(字符串枚举)。
|
||||
/// </summary>
|
||||
public string ChannelsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 目标人群类型。
|
||||
/// </summary>
|
||||
public MemberMessageAudienceType AudienceType { get; set; } = MemberMessageAudienceType.All;
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签 JSON(字符串数组)。
|
||||
/// </summary>
|
||||
public string AudienceTagsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 预计触达人数。
|
||||
/// </summary>
|
||||
public int EstimatedReachCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间类型。
|
||||
/// </summary>
|
||||
public MemberMessageScheduleType ScheduleType { get; set; } = MemberMessageScheduleType.Immediate;
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public MemberMessageStatus Status { get; set; } = MemberMessageStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际发送成功数量。
|
||||
/// </summary>
|
||||
public int SentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读数量。
|
||||
/// </summary>
|
||||
public int ReadCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化数量。
|
||||
/// </summary>
|
||||
public int ConvertedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hangfire 任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近失败摘要。
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达收件明细。
|
||||
/// </summary>
|
||||
public sealed class MemberReachRecipient : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息标识。
|
||||
/// </summary>
|
||||
public long MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public MemberMessageChannel Channel { get; set; } = MemberMessageChannel.InApp;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号快照。
|
||||
/// </summary>
|
||||
public string? Mobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信 OpenId 快照。
|
||||
/// </summary>
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送状态。
|
||||
/// </summary>
|
||||
public MemberMessageRecipientStatus Status { get; set; } = MemberMessageRecipientStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已读时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ReadAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转化时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ConvertedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败摘要。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息目标人群类型。
|
||||
/// </summary>
|
||||
public enum MemberMessageAudienceType
|
||||
{
|
||||
/// <summary>
|
||||
/// 全部会员。
|
||||
/// </summary>
|
||||
All = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 按标签筛选。
|
||||
/// </summary>
|
||||
Tags = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息发送渠道。
|
||||
/// </summary>
|
||||
public enum MemberMessageChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 站内信。
|
||||
/// </summary>
|
||||
InApp = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 短信。
|
||||
/// </summary>
|
||||
Sms = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序订阅消息。
|
||||
/// </summary>
|
||||
WeChatMini = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 消息收件明细发送状态。
|
||||
/// </summary>
|
||||
public enum MemberMessageRecipientStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待发送。
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 发送成功。
|
||||
/// </summary>
|
||||
Sent = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 发送失败。
|
||||
/// </summary>
|
||||
Failed = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 消息发送时间类型。
|
||||
/// </summary>
|
||||
public enum MemberMessageScheduleType
|
||||
{
|
||||
/// <summary>
|
||||
/// 立即发送。
|
||||
/// </summary>
|
||||
Immediate = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 定时发送。
|
||||
/// </summary>
|
||||
Scheduled = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息状态。
|
||||
/// </summary>
|
||||
public enum MemberMessageStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 草稿。
|
||||
/// </summary>
|
||||
Draft = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 待发送。
|
||||
/// </summary>
|
||||
Pending = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 发送中。
|
||||
/// </summary>
|
||||
Sending = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已发送。
|
||||
/// </summary>
|
||||
Sent = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 发送失败。
|
||||
/// </summary>
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 消息模板分类。
|
||||
/// </summary>
|
||||
public enum MemberMessageTemplateCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// 营销类。
|
||||
/// </summary>
|
||||
Marketing = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 通知类。
|
||||
/// </summary>
|
||||
Notice = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 召回类。
|
||||
/// </summary>
|
||||
Recall = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallExchangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// 纯积分。
|
||||
/// </summary>
|
||||
PointsOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 积分 + 现金。
|
||||
/// </summary>
|
||||
PointsAndCash = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分有效期模式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallExpiryMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 永久有效。
|
||||
/// </summary>
|
||||
Permanent = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 按年清零(每年 12 月 31 日)。
|
||||
/// </summary>
|
||||
YearlyClear = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 到账通知渠道。
|
||||
/// </summary>
|
||||
public enum MemberPointMallNotifyChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 站内消息。
|
||||
/// </summary>
|
||||
InApp = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 短信通知。
|
||||
/// </summary>
|
||||
Sms = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 实物领取方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallPickupMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 到店自提。
|
||||
/// </summary>
|
||||
StorePickup = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 快递配送。
|
||||
/// </summary>
|
||||
Delivery = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品状态。
|
||||
/// </summary>
|
||||
public enum MemberPointMallProductStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 下架。
|
||||
/// </summary>
|
||||
Disabled = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 上架。
|
||||
/// </summary>
|
||||
Enabled = 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录状态。
|
||||
/// </summary>
|
||||
public enum MemberPointMallRecordStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待领取。
|
||||
/// </summary>
|
||||
PendingPickup = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已发放。
|
||||
/// </summary>
|
||||
Issued = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成。
|
||||
/// </summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消。
|
||||
/// </summary>
|
||||
Canceled = 3
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分兑换类型。
|
||||
/// </summary>
|
||||
public enum MemberPointMallRedeemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
Product = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 兑换优惠券。
|
||||
/// </summary>
|
||||
Coupon = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 兑换实物。
|
||||
/// </summary>
|
||||
Physical = 2
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallVerifyMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 扫码核销。
|
||||
/// </summary>
|
||||
Scan = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 手动核销。
|
||||
/// </summary>
|
||||
Manual = 1
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息触达仓储。
|
||||
/// </summary>
|
||||
public interface IMemberMessageReachRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询消息。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<MemberReachMessage> Items, int Total)> SearchMessagesAsync(
|
||||
long tenantId,
|
||||
MemberMessageStatus? status,
|
||||
MemberMessageChannel? channel,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询消息。
|
||||
/// </summary>
|
||||
Task<MemberReachMessage?> FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增消息。
|
||||
/// </summary>
|
||||
Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新消息。
|
||||
/// </summary>
|
||||
Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息。
|
||||
/// </summary>
|
||||
Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询消息收件明细。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberReachRecipient>> GetRecipientsAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除消息收件明细。
|
||||
/// </summary>
|
||||
Task RemoveRecipientsAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量新增消息收件明细。
|
||||
/// </summary>
|
||||
Task AddRecipientsAsync(
|
||||
IReadOnlyCollection<MemberReachRecipient> recipients,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询模板。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<MemberMessageTemplate> Items, int Total)> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
MemberMessageTemplateCategory? category,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询模板。
|
||||
/// </summary>
|
||||
Task<MemberMessageTemplate?> FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按名称查询模板(忽略大小写)。
|
||||
/// </summary>
|
||||
Task<MemberMessageTemplate?> FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增模板。
|
||||
/// </summary>
|
||||
Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新模板。
|
||||
/// </summary>
|
||||
Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除模板。
|
||||
/// </summary>
|
||||
Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取月度发送统计。
|
||||
/// </summary>
|
||||
Task<MemberMessageMonthlyStatsSnapshot> GetMonthlyStatsAsync(
|
||||
long tenantId,
|
||||
DateTime monthStartUtc,
|
||||
DateTime monthEndUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会员消息月度统计快照。
|
||||
/// </summary>
|
||||
public sealed record MemberMessageMonthlyStatsSnapshot(
|
||||
int SentMessageCount,
|
||||
int ReachMemberCount,
|
||||
int SentRecipientCount,
|
||||
int ReadRecipientCount,
|
||||
int ConvertedRecipientCount);
|
||||
@@ -83,6 +83,14 @@ public interface IMemberRepository
|
||||
long memberProfileId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按会员集合批量查询标签。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
|
||||
long tenantId,
|
||||
IReadOnlyCollection<long> memberProfileIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 替换会员标签集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城仓储契约。
|
||||
/// </summary>
|
||||
public interface IPointMallRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询门店积分规则。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRule?> GetRuleByStoreAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增积分规则。
|
||||
/// </summary>
|
||||
Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新积分规则。
|
||||
/// </summary>
|
||||
Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberPointMallProduct>> SearchProductsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MemberPointMallProductStatus? status,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询兑换商品(追踪)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallProduct?> FindProductByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询兑换商品(只读)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallProduct?> GetProductByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增兑换商品。
|
||||
/// </summary>
|
||||
Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新兑换商品。
|
||||
/// </summary>
|
||||
Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品是否已有兑换记录。
|
||||
/// </summary>
|
||||
Task<bool> HasRecordsByProductIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long pointMallProductId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统计会员在某商品上的有效兑换次数(排除已取消)。
|
||||
/// </summary>
|
||||
Task<int> CountMemberRedeemsByProductAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long pointMallProductId,
|
||||
long memberId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<MemberPointMallRecord> Items, int TotalCount)> SearchRecordsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MemberPointMallRedeemType? redeemType,
|
||||
MemberPointMallRecordStatus? status,
|
||||
DateTime? startUtc,
|
||||
DateTime? endUtc,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecord?> GetRecordByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long recordId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录(追踪)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecord?> FindRecordByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long recordId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录导出数据。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberPointMallRecord>> ListRecordsForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MemberPointMallRedeemType? redeemType,
|
||||
MemberPointMallRecordStatus? status,
|
||||
DateTime? startUtc,
|
||||
DateTime? endUtc,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增兑换记录。
|
||||
/// </summary>
|
||||
Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新兑换记录。
|
||||
/// </summary>
|
||||
Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增积分流水。
|
||||
/// </summary>
|
||||
Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询规则页统计。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRuleStatsSnapshot> GetRuleStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询记录页统计。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecordStatsSnapshot> GetRecordStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品聚合统计快照。
|
||||
/// </summary>
|
||||
Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> pointMallProductIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则页统计快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallRuleStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户数。
|
||||
/// </summary>
|
||||
public int PointMembers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城记录页统计快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallRecordStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换数量。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物数量。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品聚合快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallProductAggregateSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品标识。
|
||||
/// </summary>
|
||||
public required long PointMallProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
@@ -63,6 +64,18 @@ public sealed class TenantVerificationProfile : AuditableEntityBase
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信商户号。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? WeChatMerchantNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付宝 PID。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? AlipayPid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 附加资料(JSON)。
|
||||
/// </summary>
|
||||
|
||||
@@ -52,6 +52,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
|
||||
services.AddScoped<IMemberRepository, EfMemberRepository>();
|
||||
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
|
||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
|
||||
@@ -402,6 +402,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
||||
/// <summary>
|
||||
/// 积分商城规则。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallRule> MemberPointMallRules => Set<MemberPointMallRule>();
|
||||
/// <summary>
|
||||
/// 积分商城兑换商品。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallProduct> MemberPointMallProducts => Set<MemberPointMallProduct>();
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallRecord> MemberPointMallRecords => Set<MemberPointMallRecord>();
|
||||
/// <summary>
|
||||
/// 会员储值方案。
|
||||
/// </summary>
|
||||
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
|
||||
@@ -410,6 +422,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>();
|
||||
/// <summary>
|
||||
/// 会员消息触达记录。
|
||||
/// </summary>
|
||||
public DbSet<MemberReachMessage> MemberReachMessages => Set<MemberReachMessage>();
|
||||
/// <summary>
|
||||
/// 会员消息模板。
|
||||
/// </summary>
|
||||
public DbSet<MemberMessageTemplate> MemberMessageTemplates => Set<MemberMessageTemplate>();
|
||||
/// <summary>
|
||||
/// 会员消息触达收件明细。
|
||||
/// </summary>
|
||||
public DbSet<MemberReachRecipient> MemberReachRecipients => Set<MemberReachRecipient>();
|
||||
/// <summary>
|
||||
/// 会话记录。
|
||||
/// </summary>
|
||||
public DbSet<ChatSession> ChatSessions => Set<ChatSession>();
|
||||
@@ -576,8 +600,14 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
|
||||
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
|
||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||
ConfigureMemberPointMallRule(modelBuilder.Entity<MemberPointMallRule>());
|
||||
ConfigureMemberPointMallProduct(modelBuilder.Entity<MemberPointMallProduct>());
|
||||
ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>());
|
||||
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
|
||||
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
|
||||
ConfigureMemberReachMessage(modelBuilder.Entity<MemberReachMessage>());
|
||||
ConfigureMemberMessageTemplate(modelBuilder.Entity<MemberMessageTemplate>());
|
||||
ConfigureMemberReachRecipient(modelBuilder.Entity<MemberReachRecipient>());
|
||||
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
||||
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
|
||||
ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>());
|
||||
@@ -1856,6 +1886,80 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberPointMallRule(EntityTypeBuilder<MemberPointMallRule> builder)
|
||||
{
|
||||
builder.ToTable("member_point_mall_rules");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.IsConsumeRewardEnabled).IsRequired();
|
||||
builder.Property(x => x.ConsumeAmountPerStep).IsRequired();
|
||||
builder.Property(x => x.ConsumeRewardPointsPerStep).IsRequired();
|
||||
builder.Property(x => x.IsReviewRewardEnabled).IsRequired();
|
||||
builder.Property(x => x.ReviewRewardPoints).IsRequired();
|
||||
builder.Property(x => x.IsRegisterRewardEnabled).IsRequired();
|
||||
builder.Property(x => x.RegisterRewardPoints).IsRequired();
|
||||
builder.Property(x => x.IsSigninRewardEnabled).IsRequired();
|
||||
builder.Property(x => x.SigninRewardPoints).IsRequired();
|
||||
builder.Property(x => x.ExpiryMode).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMemberPointMallProduct(EntityTypeBuilder<MemberPointMallProduct> builder)
|
||||
{
|
||||
builder.ToTable("member_point_mall_products");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.ImageUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.RedeemType).HasConversion<int>();
|
||||
builder.Property(x => x.ProductId);
|
||||
builder.Property(x => x.CouponTemplateId);
|
||||
builder.Property(x => x.PhysicalName).HasMaxLength(64);
|
||||
builder.Property(x => x.PickupMethod).HasConversion<int?>();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.ExchangeType).HasConversion<int>();
|
||||
builder.Property(x => x.RequiredPoints).IsRequired();
|
||||
builder.Property(x => x.CashAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.StockTotal).IsRequired();
|
||||
builder.Property(x => x.StockAvailable).IsRequired();
|
||||
builder.Property(x => x.PerMemberLimit);
|
||||
builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductId });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.CouponTemplateId });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberPointMallRecord(EntityTypeBuilder<MemberPointMallRecord> builder)
|
||||
{
|
||||
builder.ToTable("member_point_mall_records");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.PointMallProductId).IsRequired();
|
||||
builder.Property(x => x.MemberId).IsRequired();
|
||||
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.MemberMobileMasked).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.RedeemType).HasConversion<int>();
|
||||
builder.Property(x => x.ExchangeType).HasConversion<int>();
|
||||
builder.Property(x => x.UsedPoints).IsRequired();
|
||||
builder.Property(x => x.CashAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.RedeemedAt).IsRequired();
|
||||
builder.Property(x => x.IssuedAt);
|
||||
builder.Property(x => x.VerifiedAt);
|
||||
builder.Property(x => x.VerifyMethod).HasConversion<int?>();
|
||||
builder.Property(x => x.VerifyRemark).HasMaxLength(256);
|
||||
builder.Property(x => x.VerifiedBy);
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PointMallProductId, x.RedeemedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.MemberId, x.RedeemedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.RedeemedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RedeemedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder<MemberStoredCardPlan> builder)
|
||||
{
|
||||
builder.ToTable("member_stored_card_plans");
|
||||
@@ -1892,6 +1996,62 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberReachMessage(EntityTypeBuilder<MemberReachMessage> builder)
|
||||
{
|
||||
builder.ToTable("member_reach_messages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId);
|
||||
builder.Property(x => x.TemplateId);
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.ChannelsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.AudienceType).HasConversion<int>();
|
||||
builder.Property(x => x.AudienceTagsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.EstimatedReachCount).IsRequired();
|
||||
builder.Property(x => x.ScheduleType).HasConversion<int>();
|
||||
builder.Property(x => x.ScheduledAt);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.SentAt);
|
||||
builder.Property(x => x.SentCount).IsRequired();
|
||||
builder.Property(x => x.ReadCount).IsRequired();
|
||||
builder.Property(x => x.ConvertedCount).IsRequired();
|
||||
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
|
||||
builder.Property(x => x.LastError).HasMaxLength(1024);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.ScheduledAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberMessageTemplate(EntityTypeBuilder<MemberMessageTemplate> builder)
|
||||
{
|
||||
builder.ToTable("member_message_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>();
|
||||
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UsageCount).IsRequired();
|
||||
builder.Property(x => x.LastUsedAt);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.Category, x.UsageCount });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberReachRecipient(EntityTypeBuilder<MemberReachRecipient> builder)
|
||||
{
|
||||
builder.ToTable("member_reach_recipients");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MessageId).IsRequired();
|
||||
builder.Property(x => x.MemberId).IsRequired();
|
||||
builder.Property(x => x.Channel).HasConversion<int>();
|
||||
builder.Property(x => x.Mobile).HasMaxLength(32);
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.SentAt);
|
||||
builder.Property(x => x.ReadAt);
|
||||
builder.Property(x => x.ConvertedAt);
|
||||
builder.Property(x => x.ErrorMessage).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.MemberId, x.Channel }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.Status });
|
||||
}
|
||||
|
||||
private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder)
|
||||
{
|
||||
builder.ToTable("chat_sessions");
|
||||
@@ -2102,3 +2262,4 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user