Compare commits
5 Commits
dev
...
1efa392f36
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efa392f36 | |||
| b57b3ab228 | |||
| a8cfda88f7 | |||
| a88ca4056c | |||
| d437b146d1 |
@@ -0,0 +1,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水筛选请求。
|
||||
/// </summary>
|
||||
public class FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <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>
|
||||
/// 交易类型(income/refund/stored_card_recharge/point_redeem)。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式(wechat/alipay/cash/card/balance)。
|
||||
/// </summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(流水号/订单号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易标识(sourceType:sourceId)。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceTransactionListItemResponse> 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 decimal PageIncomeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页退款。
|
||||
/// </summary>
|
||||
public decimal PageRefundAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水行。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收入。
|
||||
/// </summary>
|
||||
public bool IsIncome { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public decimal TotalIncome { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总退款。
|
||||
/// </summary>
|
||||
public decimal TotalRefund { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总笔数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型编码。
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型文案。
|
||||
/// </summary>
|
||||
public string TypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public string OccurredAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
public string? RefundNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号。
|
||||
/// </summary>
|
||||
public string? MemberMobileMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal? RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal? GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal? ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动值。
|
||||
/// </summary>
|
||||
public int? PointChangeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动后余额。
|
||||
/// </summary>
|
||||
public int? PointBalanceAfterChange { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水导出结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionExportResponse
|
||||
{
|
||||
/// <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,343 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
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/transaction")]
|
||||
public sealed class FinanceTransactionController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:finance:transaction:view";
|
||||
private const string DetailPermission = "tenant:finance:transaction:detail";
|
||||
private const string ExportPermission = "tenant:finance:transaction:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionListResultResponse>> List(
|
||||
[FromQuery] FinanceTransactionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new SearchFinanceTransactionListQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionListResultResponse>.Ok(new FinanceTransactionListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
PageIncomeAmount = result.PageIncomeAmount,
|
||||
PageRefundAmount = result.PageRefundAmount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水统计。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionStatsResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionStatsResponse>> Stats(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起查询并映射响应。
|
||||
var result = await mediator.Send(new GetFinanceTransactionStatsQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionStatsResponse>.Ok(new FinanceTransactionStatsResponse
|
||||
{
|
||||
TotalIncome = result.TotalIncome,
|
||||
TotalRefund = result.TotalRefund,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission, DetailPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionDetailResponse>> Detail(
|
||||
[FromQuery] FinanceTransactionDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店参数与门店访问权限。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 解析交易复合标识。
|
||||
if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.BadRequest, "transactionId 非法");
|
||||
}
|
||||
|
||||
// 3. 查询详情并返回。
|
||||
var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
SourceType = sourceType,
|
||||
SourceId = sourceId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.NotFound, "交易流水不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceTransactionDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出交易流水 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("export")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceTransactionExportResponse>> Export(
|
||||
[FromQuery] FinanceTransactionFilterRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验筛选参数。
|
||||
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||
|
||||
// 2. 发起导出并返回结果。
|
||||
var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
|
||||
{
|
||||
StoreId = parsed.StoreId,
|
||||
StartAt = parsed.StartAt,
|
||||
EndAt = parsed.EndAt,
|
||||
TransactionType = parsed.TransactionType,
|
||||
DeliveryType = parsed.DeliveryType,
|
||||
PaymentMethod = parsed.PaymentMethod,
|
||||
Keyword = request.Keyword
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceTransactionExportResponse>.Ok(new FinanceTransactionExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||
FinanceTransactionFilterRequest 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, "开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
var transactionType = ParseTransactionType(request.Type);
|
||||
var deliveryType = ParseDeliveryType(request.Channel);
|
||||
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
|
||||
|
||||
return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 FinanceTransactionType? ParseTransactionType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"income" => FinanceTransactionType.Income,
|
||||
"refund" => FinanceTransactionType.Refund,
|
||||
"stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
|
||||
"point_redeem" => FinanceTransactionType.PointRedeem,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DeliveryType? ParseDeliveryType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"delivery" => DeliveryType.Delivery,
|
||||
"pickup" => DeliveryType.Pickup,
|
||||
"dine_in" => DeliveryType.DineIn,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static PaymentMethod? ParsePaymentMethod(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" => PaymentMethod.WeChatPay,
|
||||
"alipay" => PaymentMethod.Alipay,
|
||||
"cash" => PaymentMethod.Cash,
|
||||
"card" => PaymentMethod.Card,
|
||||
"balance" => PaymentMethod.Balance,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
|
||||
{
|
||||
sourceType = default;
|
||||
sourceId = 0;
|
||||
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sourceType = parts[0].ToLowerInvariant() switch
|
||||
{
|
||||
"payment" => FinanceTransactionSourceType.PaymentRecord,
|
||||
"payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
|
||||
"refund_request" => FinanceTransactionSourceType.RefundRequest,
|
||||
"stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||
"member_point" => FinanceTransactionSourceType.MemberPointLedger,
|
||||
_ => default
|
||||
};
|
||||
|
||||
return sourceType != default;
|
||||
}
|
||||
|
||||
private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
|
||||
{
|
||||
return new FinanceTransactionListItemResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
OrderNo = source.OrderNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
IsIncome = source.IsIncome
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
|
||||
{
|
||||
return new FinanceTransactionDetailResponse
|
||||
{
|
||||
TransactionId = source.TransactionId,
|
||||
TransactionNo = source.TransactionNo,
|
||||
Type = source.TransactionType,
|
||||
TypeText = source.TransactionTypeText,
|
||||
StoreId = source.StoreId.ToString(),
|
||||
OrderNo = source.OrderNo,
|
||||
Channel = source.ChannelText,
|
||||
PaymentMethod = source.PaymentMethodText,
|
||||
Amount = source.AmountSigned,
|
||||
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
Remark = source.Remark,
|
||||
CustomerName = source.CustomerName,
|
||||
CustomerPhone = source.CustomerPhone,
|
||||
RefundNo = source.RefundNo,
|
||||
RefundReason = source.RefundReason,
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PointChangeAmount = source.PointChangeAmount,
|
||||
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,256 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表行。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型编码。
|
||||
/// </summary>
|
||||
public string TransactionType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型文案。
|
||||
/// </summary>
|
||||
public string TransactionTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethodText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额(带符号)。
|
||||
/// </summary>
|
||||
public decimal AmountSigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收入。
|
||||
/// </summary>
|
||||
public bool IsIncome { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页数据。
|
||||
/// </summary>
|
||||
public List<FinanceTransactionListItemDto> 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 decimal PageIncomeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页退款合计。
|
||||
/// </summary>
|
||||
public decimal PageRefundAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public decimal TotalIncome { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总退款。
|
||||
/// </summary>
|
||||
public decimal TotalRefund { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易笔数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 交易标识。
|
||||
/// </summary>
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 流水号。
|
||||
/// </summary>
|
||||
public string TransactionNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型编码。
|
||||
/// </summary>
|
||||
public string TransactionType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型文案。
|
||||
/// </summary>
|
||||
public string TransactionTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式文案。
|
||||
/// </summary>
|
||||
public string PaymentMethodText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额(带符号)。
|
||||
/// </summary>
|
||||
public decimal AmountSigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
public string? RefundNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号脱敏值。
|
||||
/// </summary>
|
||||
public string? MemberMobileMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal? RechargeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal? GiftAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal? ArrivedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动值。
|
||||
/// </summary>
|
||||
public int? PointChangeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动后余额。
|
||||
/// </summary>
|
||||
public int? PointBalanceAfterChange { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水导出结果。
|
||||
/// </summary>
|
||||
public sealed class FinanceTransactionExportDto
|
||||
{
|
||||
/// <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,81 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水 CSV 导出查询处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceTransactionCsvQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportFinanceTransactionCsvQuery, FinanceTransactionExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionExportDto> Handle(ExportFinanceTransactionCsvQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 按筛选读取导出数据。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var records = await financeTransactionRepository.ListForExportAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.TransactionType,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 组装 CSV 并输出 Base64。
|
||||
var csv = BuildCsv(records.Select(FinanceTransactionMapping.ToListItem).ToList());
|
||||
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||
|
||||
return new FinanceTransactionExportDto
|
||||
{
|
||||
FileName = $"交易流水_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||
TotalCount = records.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildCsv(IReadOnlyList<FinanceTransactionListItemDto> rows)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("流水号,关联订单,类型,渠道,支付方式,金额,交易时间,备注");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = new[]
|
||||
{
|
||||
Escape(row.TransactionNo),
|
||||
Escape(string.IsNullOrWhiteSpace(row.OrderNo) ? "—" : row.OrderNo!),
|
||||
Escape(row.TransactionTypeText),
|
||||
Escape(row.ChannelText),
|
||||
Escape(row.PaymentMethodText),
|
||||
Escape(FinanceTransactionMapping.FormatAmount(row.AmountSigned)),
|
||||
Escape(row.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
Escape(string.IsNullOrWhiteSpace(row.Remark) ? "—" : row.Remark)
|
||||
};
|
||||
|
||||
builder.AppendLine(string.Join(',', cells));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (!value.Contains('"') && !value.Contains(',') && !value.Contains('\n') && !value.Contains('\r'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水映射与文案转换。
|
||||
/// </summary>
|
||||
internal static class FinanceTransactionMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成交易复合标识。
|
||||
/// </summary>
|
||||
public static string BuildTransactionId(FinanceTransactionSourceType sourceType, long sourceId)
|
||||
{
|
||||
var sourceCode = sourceType switch
|
||||
{
|
||||
FinanceTransactionSourceType.PaymentRecord => "payment",
|
||||
FinanceTransactionSourceType.PaymentRefundRecord => "payment_refund",
|
||||
FinanceTransactionSourceType.RefundRequest => "refund_request",
|
||||
FinanceTransactionSourceType.StoredCardRechargeRecord => "stored_card_recharge",
|
||||
FinanceTransactionSourceType.MemberPointLedger => "member_point",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
return $"{sourceCode}:{sourceId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析交易类型编码。
|
||||
/// </summary>
|
||||
public static string ToTransactionTypeCode(FinanceTransactionType transactionType)
|
||||
{
|
||||
return transactionType switch
|
||||
{
|
||||
FinanceTransactionType.Income => "income",
|
||||
FinanceTransactionType.Refund => "refund",
|
||||
FinanceTransactionType.StoredCardRecharge => "stored_card_recharge",
|
||||
FinanceTransactionType.PointRedeem => "point_redeem",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析交易类型文案。
|
||||
/// </summary>
|
||||
public static string ToTransactionTypeText(FinanceTransactionType transactionType)
|
||||
{
|
||||
return transactionType switch
|
||||
{
|
||||
FinanceTransactionType.Income => "收入",
|
||||
FinanceTransactionType.Refund => "退款",
|
||||
FinanceTransactionType.StoredCardRecharge => "储值充值",
|
||||
FinanceTransactionType.PointRedeem => "积分抵扣",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析渠道文案。
|
||||
/// </summary>
|
||||
public static string ToChannelText(DeliveryType? deliveryType)
|
||||
{
|
||||
return deliveryType switch
|
||||
{
|
||||
DeliveryType.Delivery => "外卖",
|
||||
DeliveryType.Pickup => "自提",
|
||||
DeliveryType.DineIn => "堂食",
|
||||
_ => "—"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析支付方式文案。
|
||||
/// </summary>
|
||||
public static string ToPaymentMethodText(PaymentMethod? paymentMethod)
|
||||
{
|
||||
return paymentMethod switch
|
||||
{
|
||||
PaymentMethod.WeChatPay => "微信",
|
||||
PaymentMethod.Alipay => "支付宝",
|
||||
PaymentMethod.Cash => "现金",
|
||||
PaymentMethod.Card => "刷卡",
|
||||
PaymentMethod.Balance => "储值余额",
|
||||
_ => "—"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射列表行。
|
||||
/// </summary>
|
||||
public static FinanceTransactionListItemDto ToListItem(FinanceTransactionRecord source)
|
||||
{
|
||||
return new FinanceTransactionListItemDto
|
||||
{
|
||||
TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
|
||||
TransactionNo = source.TransactionNo ?? string.Empty,
|
||||
OrderNo = source.OrderNo,
|
||||
TransactionType = ToTransactionTypeCode(source.TransactionType),
|
||||
TransactionTypeText = ToTransactionTypeText(source.TransactionType),
|
||||
ChannelText = ToChannelText(source.DeliveryType),
|
||||
PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
|
||||
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||
OccurredAt = source.OccurredAt,
|
||||
Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
|
||||
IsIncome = source.AmountSigned > 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射详情。
|
||||
/// </summary>
|
||||
public static FinanceTransactionDetailDto ToDetail(FinanceTransactionRecord source)
|
||||
{
|
||||
return new FinanceTransactionDetailDto
|
||||
{
|
||||
TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
|
||||
TransactionNo = source.TransactionNo ?? string.Empty,
|
||||
TransactionType = ToTransactionTypeCode(source.TransactionType),
|
||||
TransactionTypeText = ToTransactionTypeText(source.TransactionType),
|
||||
StoreId = source.StoreId,
|
||||
OrderNo = source.OrderNo,
|
||||
ChannelText = ToChannelText(source.DeliveryType),
|
||||
PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
|
||||
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||
OccurredAt = source.OccurredAt,
|
||||
Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
|
||||
CustomerName = string.IsNullOrWhiteSpace(source.CustomerName) ? "—" : source.CustomerName.Trim(),
|
||||
CustomerPhone = string.IsNullOrWhiteSpace(source.CustomerPhone) ? "—" : source.CustomerPhone.Trim(),
|
||||
RefundNo = source.RefundNo,
|
||||
RefundReason = source.RefundReason,
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PointChangeAmount = source.PointChangeAmount,
|
||||
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出金额文本。
|
||||
/// </summary>
|
||||
public static string FormatAmount(decimal amountSigned)
|
||||
{
|
||||
var rounded = decimal.Round(amountSigned, 2, MidpointRounding.AwayFromZero);
|
||||
var sign = rounded >= 0 ? "+" : string.Empty;
|
||||
return $"{sign}{rounded.ToString("0.00", CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionDetailQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceTransactionDetailQuery, FinanceTransactionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionDetailDto?> Handle(GetFinanceTransactionDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并查询详情。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var record = await financeTransactionRepository.GetDetailAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.SourceType,
|
||||
request.SourceId,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 映射详情输出。
|
||||
return record is null ? null : FinanceTransactionMapping.ToDetail(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionStatsQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceTransactionStatsQuery, FinanceTransactionStatsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionStatsDto> Handle(GetFinanceTransactionStatsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并执行统计查询。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var snapshot = await financeTransactionRepository.GetStatsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.TransactionType,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 映射统计结果。
|
||||
return new FinanceTransactionStatsDto
|
||||
{
|
||||
TotalIncome = snapshot.TotalIncome,
|
||||
TotalRefund = snapshot.TotalRefund,
|
||||
TotalCount = snapshot.TotalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceTransactionListQueryHandler(
|
||||
IFinanceTransactionRepository financeTransactionRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchFinanceTransactionListQuery, FinanceTransactionListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionListResultDto> Handle(SearchFinanceTransactionListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并执行分页查询。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
var snapshot = await financeTransactionRepository.SearchPageAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.TransactionType,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 映射结果并返回。
|
||||
return new FinanceTransactionListResultDto
|
||||
{
|
||||
Items = snapshot.Items.Select(FinanceTransactionMapping.ToListItem).ToList(),
|
||||
Total = snapshot.TotalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
PageIncomeAmount = snapshot.PageIncomeAmount,
|
||||
PageRefundAmount = snapshot.PageRefundAmount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水 CSV 导出查询。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceTransactionCsvQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionExportDto>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水筛选查询基类。
|
||||
/// </summary>
|
||||
public abstract class FinanceTransactionFilterQueryBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(含)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(不含)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型。
|
||||
/// </summary>
|
||||
public FinanceTransactionType? TransactionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionDetailQuery : IRequest<FinanceTransactionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
public FinanceTransactionSourceType SourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源标识。
|
||||
/// </summary>
|
||||
public long SourceId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计查询。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionStatsQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionStatsDto>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表查询。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceTransactionListQuery : FinanceTransactionFilterQueryBase, IRequest<FinanceTransactionListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水导出查询验证器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceTransactionCsvQueryValidator : AbstractValidator<ExportFinanceTransactionCsvQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ExportFinanceTransactionCsvQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水详情查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionDetailQueryValidator : AbstractValidator<GetFinanceTransactionDetailQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public GetFinanceTransactionDetailQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.SourceId).GreaterThan(0);
|
||||
RuleFor(x => x.SourceType)
|
||||
.Must(x => x is FinanceTransactionSourceType.PaymentRecord
|
||||
or FinanceTransactionSourceType.PaymentRefundRecord
|
||||
or FinanceTransactionSourceType.RefundRequest
|
||||
or FinanceTransactionSourceType.StoredCardRechargeRecord
|
||||
or FinanceTransactionSourceType.MemberPointLedger)
|
||||
.WithMessage("sourceType 非法");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceTransactionStatsQueryValidator : AbstractValidator<GetFinanceTransactionStatsQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public GetFinanceTransactionStatsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水列表查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceTransactionListQueryValidator : AbstractValidator<SearchFinanceTransactionListQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SearchFinanceTransactionListQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -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,32 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 交易来源类型。
|
||||
/// </summary>
|
||||
public enum FinanceTransactionSourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付流水。
|
||||
/// </summary>
|
||||
PaymentRecord = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 渠道退款流水。
|
||||
/// </summary>
|
||||
PaymentRefundRecord = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 退款申请(补充来源)。
|
||||
/// </summary>
|
||||
RefundRequest = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 储值充值记录。
|
||||
/// </summary>
|
||||
StoredCardRechargeRecord = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分流水。
|
||||
/// </summary>
|
||||
MemberPointLedger = 5
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型。
|
||||
/// </summary>
|
||||
public enum FinanceTransactionType
|
||||
{
|
||||
/// <summary>
|
||||
/// 收入。
|
||||
/// </summary>
|
||||
Income = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 退款。
|
||||
/// </summary>
|
||||
Refund = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 储值充值。
|
||||
/// </summary>
|
||||
StoredCardRecharge = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 积分抵扣。
|
||||
/// </summary>
|
||||
PointRedeem = 4
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统一记录。
|
||||
/// </summary>
|
||||
public sealed record FinanceTransactionRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
public required FinanceTransactionSourceType SourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源标识。
|
||||
/// </summary>
|
||||
public required long SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属门店。
|
||||
/// </summary>
|
||||
public required long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易单号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型。
|
||||
/// </summary>
|
||||
public required FinanceTransactionType TransactionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单标识。
|
||||
/// </summary>
|
||||
public long? OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string? OrderNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易金额(收入为正,退款为负)。
|
||||
/// </summary>
|
||||
public required decimal AmountSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易时间。
|
||||
/// </summary>
|
||||
public required DateTime OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string? CustomerPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
public string? RefundNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款原因。
|
||||
/// </summary>
|
||||
public string? RefundReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string? MemberName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号脱敏值。
|
||||
/// </summary>
|
||||
public string? MemberMobileMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 充值金额。
|
||||
/// </summary>
|
||||
public decimal? RechargeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送金额。
|
||||
/// </summary>
|
||||
public decimal? GiftAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账金额。
|
||||
/// </summary>
|
||||
public decimal? ArrivedAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动值。
|
||||
/// </summary>
|
||||
public int? PointChangeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分变动后余额。
|
||||
/// </summary>
|
||||
public int? PointBalanceAfterChange { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水分页快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceTransactionPageSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页记录。
|
||||
/// </summary>
|
||||
public required IReadOnlyList<FinanceTransactionRecord> Items { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页收入合计。
|
||||
/// </summary>
|
||||
public required decimal PageIncomeAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本页退款合计。
|
||||
/// </summary>
|
||||
public required decimal PageRefundAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易流水统计快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceTransactionStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public required decimal TotalIncome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总退款。
|
||||
/// </summary>
|
||||
public required decimal TotalRefund { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总交易笔数。
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 财务交易流水仓储契约。
|
||||
/// </summary>
|
||||
public interface IFinanceTransactionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询交易流水分页。
|
||||
/// </summary>
|
||||
Task<FinanceTransactionPageSnapshot> SearchPageAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水统计。
|
||||
/// </summary>
|
||||
Task<FinanceTransactionStatsSnapshot> GetStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询交易流水详情。
|
||||
/// </summary>
|
||||
Task<FinanceTransactionRecord?> GetDetailAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceTransactionSourceType sourceType,
|
||||
long sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询导出数据。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FinanceTransactionRecord>> ListForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
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,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,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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
@@ -51,8 +52,10 @@ 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>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||
|
||||
@@ -422,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>();
|
||||
@@ -593,6 +605,9 @@ public sealed class TakeoutAppDbContext(
|
||||
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>());
|
||||
@@ -1981,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");
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 财务交易流水 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context) : IFinanceTransactionRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionPageSnapshot> SearchPageAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建筛选查询并读取总量。
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
if (totalCount == 0)
|
||||
{
|
||||
return new FinanceTransactionPageSnapshot
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
PageIncomeAmount = 0,
|
||||
PageRefundAmount = 0
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 分页读取记录并完成统一映射。
|
||||
var rows = await query
|
||||
.OrderByDescending(item => item.OccurredAt)
|
||||
.ThenByDescending(item => item.SourceId)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = rows.Select(MapToRecord).ToList();
|
||||
|
||||
// 3. 汇总本页收入与退款。
|
||||
var pageIncomeAmount = items
|
||||
.Where(item => item.AmountSigned > 0)
|
||||
.Sum(item => item.AmountSigned);
|
||||
var pageRefundAmount = items
|
||||
.Where(item => item.AmountSigned < 0)
|
||||
.Sum(item => Math.Abs(item.AmountSigned));
|
||||
|
||||
return new FinanceTransactionPageSnapshot
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageIncomeAmount = decimal.Round(pageIncomeAmount, 2, MidpointRounding.AwayFromZero),
|
||||
PageRefundAmount = decimal.Round(pageRefundAmount, 2, MidpointRounding.AwayFromZero)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionStatsSnapshot> GetStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建筛选查询并聚合总览指标。
|
||||
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||
|
||||
var summary = await query
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
TotalIncome = group
|
||||
.Where(item => item.AmountSigned > 0)
|
||||
.Sum(item => item.AmountSigned),
|
||||
TotalRefund = group
|
||||
.Where(item => item.AmountSigned < 0)
|
||||
.Sum(item => Math.Abs(item.AmountSigned)),
|
||||
TotalCount = group.Count()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (summary is null)
|
||||
{
|
||||
return new FinanceTransactionStatsSnapshot
|
||||
{
|
||||
TotalIncome = 0,
|
||||
TotalRefund = 0,
|
||||
TotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
return new FinanceTransactionStatsSnapshot
|
||||
{
|
||||
TotalIncome = decimal.Round(summary.TotalIncome, 2, MidpointRounding.AwayFromZero),
|
||||
TotalRefund = decimal.Round(summary.TotalRefund, 2, MidpointRounding.AwayFromZero),
|
||||
TotalCount = summary.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceTransactionRecord?> GetDetailAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceTransactionSourceType sourceType,
|
||||
long sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按来源组合键定位唯一流水。
|
||||
var query = BuildQuery(
|
||||
tenantId,
|
||||
storeId,
|
||||
startAt: null,
|
||||
endAt: null,
|
||||
transactionType: null,
|
||||
deliveryType: null,
|
||||
paymentMethod: null,
|
||||
keyword: null);
|
||||
|
||||
var row = await query
|
||||
.Where(item => item.SourceType == sourceType && item.SourceId == sourceId)
|
||||
.OrderByDescending(item => item.OccurredAt)
|
||||
.ThenByDescending(item => item.SourceId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return row is null ? null : MapToRecord(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FinanceTransactionRecord>> ListForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按筛选读取导出数据,限定上限保障性能。
|
||||
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(item => item.OccurredAt)
|
||||
.ThenByDescending(item => item.SourceId)
|
||||
.Take(20_000)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows.Select(MapToRecord).ToList();
|
||||
}
|
||||
|
||||
private IQueryable<TransactionProjection> BuildQuery(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
FinanceTransactionType? transactionType,
|
||||
DeliveryType? deliveryType,
|
||||
PaymentMethod? paymentMethod,
|
||||
string? keyword)
|
||||
{
|
||||
// 1. 收入流水(支付成功)。
|
||||
var incomeQuery =
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
select new TransactionProjection
|
||||
{
|
||||
SourceType = FinanceTransactionSourceType.PaymentRecord,
|
||||
SourceId = payment.Id,
|
||||
StoreId = order.StoreId,
|
||||
TransactionType = FinanceTransactionType.Income,
|
||||
TransactionNo = payment.TradeNo,
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
DeliveryType = order.DeliveryType,
|
||||
PaymentMethod = payment.Method,
|
||||
AmountSigned = payment.Amount,
|
||||
OccurredAt = payment.PaidAt ?? payment.CreatedAt,
|
||||
Remark = payment.Remark,
|
||||
CustomerName = order.CustomerName,
|
||||
CustomerPhone = order.CustomerPhone
|
||||
};
|
||||
|
||||
// 2. 渠道退款流水(优先来源)。
|
||||
var channelRefundQuery =
|
||||
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
join payment in context.PaymentRecords.AsNoTracking()
|
||||
on refund.PaymentRecordId equals payment.Id into paymentGroup
|
||||
from matchedPayment in paymentGroup.DefaultIfEmpty()
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||
select new TransactionProjection
|
||||
{
|
||||
SourceType = FinanceTransactionSourceType.PaymentRefundRecord,
|
||||
SourceId = refund.Id,
|
||||
StoreId = order.StoreId,
|
||||
TransactionType = FinanceTransactionType.Refund,
|
||||
TransactionNo = refund.ChannelRefundId,
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
DeliveryType = order.DeliveryType,
|
||||
PaymentMethod = matchedPayment == null ? null : matchedPayment.Method,
|
||||
AmountSigned = 0 - refund.Amount,
|
||||
OccurredAt = refund.CompletedAt ?? refund.RequestedAt,
|
||||
Remark = null,
|
||||
CustomerName = order.CustomerName,
|
||||
CustomerPhone = order.CustomerPhone
|
||||
};
|
||||
|
||||
// 3. 退款申请流水(补充来源:没有渠道退款记录时使用)。
|
||||
var requestRefundQuery =
|
||||
from refund in context.RefundRequests.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& refund.Status == RefundStatus.Refunded
|
||||
&& !context.PaymentRefundRecords
|
||||
.AsNoTracking()
|
||||
.Any(channelRefund =>
|
||||
channelRefund.TenantId == tenantId
|
||||
&& channelRefund.OrderId == refund.OrderId
|
||||
&& channelRefund.Status == PaymentRefundStatus.Succeeded)
|
||||
select new TransactionProjection
|
||||
{
|
||||
SourceType = FinanceTransactionSourceType.RefundRequest,
|
||||
SourceId = refund.Id,
|
||||
StoreId = order.StoreId,
|
||||
TransactionType = FinanceTransactionType.Refund,
|
||||
TransactionNo = refund.RefundNo,
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
DeliveryType = order.DeliveryType,
|
||||
PaymentMethod = context.PaymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
|
||||
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
|
||||
.ThenByDescending(payment => payment.Id)
|
||||
.Select(payment => (PaymentMethod?)payment.Method)
|
||||
.FirstOrDefault(),
|
||||
AmountSigned = 0 - refund.Amount,
|
||||
OccurredAt = refund.ProcessedAt ?? refund.RequestedAt,
|
||||
Remark = refund.ReviewNotes,
|
||||
RefundNo = refund.RefundNo,
|
||||
RefundReason = refund.Reason,
|
||||
CustomerName = order.CustomerName,
|
||||
CustomerPhone = order.CustomerPhone
|
||||
};
|
||||
|
||||
// 4. 储值充值流水。
|
||||
var rechargeQuery =
|
||||
from recharge in context.MemberStoredCardRechargeRecords.AsNoTracking()
|
||||
where recharge.TenantId == tenantId
|
||||
select new TransactionProjection
|
||||
{
|
||||
SourceType = FinanceTransactionSourceType.StoredCardRechargeRecord,
|
||||
SourceId = recharge.Id,
|
||||
StoreId = recharge.StoreId,
|
||||
TransactionType = FinanceTransactionType.StoredCardRecharge,
|
||||
TransactionNo = recharge.RecordNo,
|
||||
OrderId = null,
|
||||
OrderNo = null,
|
||||
DeliveryType = null,
|
||||
PaymentMethod = recharge.PaymentMethod,
|
||||
AmountSigned = recharge.RechargeAmount,
|
||||
OccurredAt = recharge.RechargedAt,
|
||||
Remark = recharge.Remark,
|
||||
MemberName = recharge.MemberName,
|
||||
MemberMobileMasked = recharge.MemberMobileMasked,
|
||||
RechargeAmount = recharge.RechargeAmount,
|
||||
GiftAmount = recharge.GiftAmount,
|
||||
ArrivedAmount = recharge.ArrivedAmount
|
||||
};
|
||||
|
||||
// 5. 积分抵扣流水(仅统计可关联订单)。
|
||||
var pointRedeemQuery =
|
||||
from pointLedger in context.MemberPointLedgers.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on pointLedger.SourceId equals (long?)order.Id
|
||||
where pointLedger.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& pointLedger.SourceId.HasValue
|
||||
&& pointLedger.Reason == PointChangeReason.Redeem
|
||||
&& pointLedger.ChangeAmount < 0
|
||||
select new TransactionProjection
|
||||
{
|
||||
SourceType = FinanceTransactionSourceType.MemberPointLedger,
|
||||
SourceId = pointLedger.Id,
|
||||
StoreId = order.StoreId,
|
||||
TransactionType = FinanceTransactionType.PointRedeem,
|
||||
TransactionNo = null,
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
DeliveryType = order.DeliveryType,
|
||||
PaymentMethod = context.PaymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
|
||||
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
|
||||
.ThenByDescending(payment => payment.Id)
|
||||
.Select(payment => (PaymentMethod?)payment.Method)
|
||||
.FirstOrDefault(),
|
||||
AmountSigned = order.DiscountAmount,
|
||||
OccurredAt = pointLedger.OccurredAt,
|
||||
Remark = null,
|
||||
PointChangeAmount = pointLedger.ChangeAmount,
|
||||
PointBalanceAfterChange = pointLedger.BalanceAfterChange,
|
||||
CustomerName = order.CustomerName,
|
||||
CustomerPhone = order.CustomerPhone
|
||||
};
|
||||
|
||||
// 6. 合并多来源并下推统一筛选条件。
|
||||
var query = incomeQuery
|
||||
.Concat(channelRefundQuery)
|
||||
.Concat(requestRefundQuery)
|
||||
.Concat(rechargeQuery)
|
||||
.Concat(pointRedeemQuery)
|
||||
.Where(item => item.StoreId == storeId);
|
||||
|
||||
if (startAt.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.OccurredAt >= startAt.Value);
|
||||
}
|
||||
|
||||
if (endAt.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.OccurredAt < endAt.Value);
|
||||
}
|
||||
|
||||
if (transactionType.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.TransactionType == transactionType.Value);
|
||||
}
|
||||
|
||||
if (deliveryType.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.DeliveryType == deliveryType.Value);
|
||||
}
|
||||
|
||||
if (paymentMethod.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
|
||||
}
|
||||
|
||||
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
var like = $"%{normalizedKeyword}%";
|
||||
query = query.Where(item =>
|
||||
EF.Functions.ILike(item.TransactionNo ?? string.Empty, like)
|
||||
|| EF.Functions.ILike(item.OrderNo ?? string.Empty, like)
|
||||
|| EF.Functions.ILike(item.RefundNo ?? string.Empty, like)
|
||||
|| EF.Functions.ILike(item.MemberName ?? string.Empty, like)
|
||||
|| EF.Functions.ILike(item.MemberMobileMasked ?? string.Empty, like));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static FinanceTransactionRecord MapToRecord(TransactionProjection source)
|
||||
{
|
||||
return new FinanceTransactionRecord
|
||||
{
|
||||
SourceType = source.SourceType,
|
||||
SourceId = source.SourceId,
|
||||
StoreId = source.StoreId,
|
||||
TransactionNo = ResolveTransactionNo(source),
|
||||
TransactionType = source.TransactionType,
|
||||
OrderId = source.OrderId,
|
||||
OrderNo = source.OrderNo,
|
||||
DeliveryType = source.DeliveryType,
|
||||
PaymentMethod = source.PaymentMethod,
|
||||
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
|
||||
OccurredAt = source.OccurredAt,
|
||||
Remark = ResolveRemark(source),
|
||||
CustomerName = source.CustomerName,
|
||||
CustomerPhone = source.CustomerPhone,
|
||||
RefundNo = source.RefundNo,
|
||||
RefundReason = source.RefundReason,
|
||||
MemberName = source.MemberName,
|
||||
MemberMobileMasked = source.MemberMobileMasked,
|
||||
RechargeAmount = source.RechargeAmount,
|
||||
GiftAmount = source.GiftAmount,
|
||||
ArrivedAmount = source.ArrivedAmount,
|
||||
PointChangeAmount = source.PointChangeAmount,
|
||||
PointBalanceAfterChange = source.PointBalanceAfterChange
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveTransactionNo(TransactionProjection source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source.TransactionNo))
|
||||
{
|
||||
return source.TransactionNo.Trim();
|
||||
}
|
||||
|
||||
return source.SourceType switch
|
||||
{
|
||||
FinanceTransactionSourceType.PaymentRecord => $"PAY{source.SourceId}",
|
||||
FinanceTransactionSourceType.PaymentRefundRecord => $"REFUND{source.SourceId}",
|
||||
FinanceTransactionSourceType.RefundRequest => $"REFREQ{source.SourceId}",
|
||||
FinanceTransactionSourceType.StoredCardRechargeRecord => $"RECHARGE{source.SourceId}",
|
||||
FinanceTransactionSourceType.MemberPointLedger => $"POINT{source.SourceId}",
|
||||
_ => $"TXN{source.SourceId}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRemark(TransactionProjection source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source.Remark))
|
||||
{
|
||||
return source.Remark.Trim();
|
||||
}
|
||||
|
||||
if (source.TransactionType == FinanceTransactionType.PointRedeem && source.PointChangeAmount.HasValue)
|
||||
{
|
||||
return $"积分抵扣{Math.Abs(source.PointChangeAmount.Value)}分";
|
||||
}
|
||||
|
||||
return source.TransactionType switch
|
||||
{
|
||||
FinanceTransactionType.Income => "订单收款",
|
||||
FinanceTransactionType.Refund => "订单退款",
|
||||
FinanceTransactionType.StoredCardRecharge => "会员储值充值",
|
||||
FinanceTransactionType.PointRedeem => "积分抵扣",
|
||||
_ => "--"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TransactionProjection
|
||||
{
|
||||
public required FinanceTransactionSourceType SourceType { get; init; }
|
||||
|
||||
public required long SourceId { get; init; }
|
||||
|
||||
public required long StoreId { get; init; }
|
||||
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
public required FinanceTransactionType TransactionType { get; init; }
|
||||
|
||||
public long? OrderId { get; init; }
|
||||
|
||||
public string? OrderNo { get; init; }
|
||||
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
public required decimal AmountSigned { get; init; }
|
||||
|
||||
public required DateTime OccurredAt { get; init; }
|
||||
|
||||
public string? Remark { get; init; }
|
||||
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
public string? CustomerPhone { get; init; }
|
||||
|
||||
public string? RefundNo { get; init; }
|
||||
|
||||
public string? RefundReason { get; init; }
|
||||
|
||||
public string? MemberName { get; init; }
|
||||
|
||||
public string? MemberMobileMasked { get; init; }
|
||||
|
||||
public decimal? RechargeAmount { get; init; }
|
||||
|
||||
public decimal? GiftAmount { get; init; }
|
||||
|
||||
public decimal? ArrivedAmount { get; init; }
|
||||
|
||||
public int? PointChangeAmount { get; init; }
|
||||
|
||||
public int? PointBalanceAfterChange { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 会员消息触达仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMemberMessageReachRepository(TakeoutAppDbContext context) : IMemberMessageReachRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MemberReachMessage> Items, int Total)> SearchMessagesAsync(
|
||||
long tenantId,
|
||||
MemberMessageStatus? status,
|
||||
MemberMessageChannel? channel,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.MemberReachMessages
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Status == status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(item => EF.Functions.ILike(item.Title, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
var source = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channel.HasValue)
|
||||
{
|
||||
source = source
|
||||
.Where(item => HasChannel(item.ChannelsJson, channel.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var total = source.Count;
|
||||
var items = source
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberReachMessage?> FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberReachMessages
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == messageId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberReachMessages.AddAsync(message, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberReachMessages.Update(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberReachMessages.Remove(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberReachRecipient>> GetRecipientsAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.MemberReachRecipients
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
|
||||
.OrderBy(item => item.MemberId)
|
||||
.ThenBy(item => item.Channel)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveRecipientsAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var recipients = await context.MemberReachRecipients
|
||||
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.MemberReachRecipients.RemoveRange(recipients);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddRecipientsAsync(
|
||||
IReadOnlyCollection<MemberReachRecipient> recipients,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await context.MemberReachRecipients.AddRangeAsync(recipients, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MemberMessageTemplate> Items, int Total)> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
MemberMessageTemplateCategory? category,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.MemberMessageTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId);
|
||||
|
||||
if (category.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Category == category.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(item => EF.Functions.ILike(item.Name, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.UsageCount)
|
||||
.ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberMessageTemplate?> FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberMessageTemplates
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == templateId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberMessageTemplate?> FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedName = (name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
return Task.FromResult<MemberMessageTemplate?>(null);
|
||||
}
|
||||
|
||||
return context.MemberMessageTemplates
|
||||
.FirstOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == tenantId &&
|
||||
EF.Functions.ILike(item.Name, normalizedName),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberMessageTemplates.AddAsync(template, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberMessageTemplates.Update(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberMessageTemplates.Remove(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageMonthlyStatsSnapshot> GetMonthlyStatsAsync(
|
||||
long tenantId,
|
||||
DateTime monthStartUtc,
|
||||
DateTime monthEndUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sentMessageCount = await context.MemberReachMessages
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.Status == MemberMessageStatus.Sent &&
|
||||
item.SentAt.HasValue &&
|
||||
item.SentAt.Value >= monthStartUtc &&
|
||||
item.SentAt.Value < monthEndUtc)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var recipients = await context.MemberReachRecipients
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.SentAt.HasValue &&
|
||||
item.SentAt.Value >= monthStartUtc &&
|
||||
item.SentAt.Value < monthEndUtc)
|
||||
.Select(item => new
|
||||
{
|
||||
item.MemberId,
|
||||
item.Status,
|
||||
item.ReadAt,
|
||||
item.ConvertedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var reachMemberCount = recipients
|
||||
.Where(item => item.Status == MemberMessageRecipientStatus.Sent)
|
||||
.Select(item => item.MemberId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
var sentRecipientCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
|
||||
var readRecipientCount = recipients.Count(item => item.ReadAt.HasValue);
|
||||
var convertedRecipientCount = recipients.Count(item => item.ConvertedAt.HasValue);
|
||||
|
||||
return new MemberMessageMonthlyStatsSnapshot(
|
||||
sentMessageCount,
|
||||
reachMemberCount,
|
||||
sentRecipientCount,
|
||||
readRecipientCount,
|
||||
convertedRecipientCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool HasChannel(string channelsJson, MemberMessageChannel channel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channelsJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var channels = JsonSerializer.Deserialize<List<string>>(channelsJson) ?? [];
|
||||
var target = channel switch
|
||||
{
|
||||
MemberMessageChannel.InApp => "inapp",
|
||||
MemberMessageChannel.Sms => "sms",
|
||||
MemberMessageChannel.WeChatMini => "wechat-mini",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return channels.Any(item => string.Equals(item, target, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,25 @@ public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRep
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
|
||||
long tenantId,
|
||||
IReadOnlyCollection<long> memberProfileIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (memberProfileIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.MemberProfileTags
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && memberProfileIds.Contains(x.MemberProfileId))
|
||||
.OrderBy(x => x.MemberProfileId)
|
||||
.ThenBy(x => x.TagName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReplaceProfileTagsAsync(
|
||||
long tenantId,
|
||||
|
||||
@@ -27,6 +27,23 @@ public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUse
|
||||
public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
|
||||
IReadOnlyCollection<long> ids,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MiniUsers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新小程序用户信息。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMemberMessageReachModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_message_templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "模板名称。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "模板分类。"),
|
||||
Content = table.Column<string>(type: "text", nullable: false, comment: "模板内容。"),
|
||||
UsageCount = table.Column<int>(type: "integer", nullable: false, comment: "使用次数。"),
|
||||
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近使用时间(UTC)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_message_templates", x => x.Id);
|
||||
},
|
||||
comment: "会员消息模板。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_reach_messages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识。"),
|
||||
TemplateId = table.Column<long>(type: "bigint", nullable: true, comment: "模板标识。"),
|
||||
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "消息标题。"),
|
||||
Content = table.Column<string>(type: "text", nullable: false, comment: "消息内容。"),
|
||||
ChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "发送渠道 JSON。"),
|
||||
AudienceType = table.Column<int>(type: "integer", nullable: false, comment: "目标人群类型。"),
|
||||
AudienceTagsJson = table.Column<string>(type: "text", nullable: false, comment: "目标标签 JSON。"),
|
||||
EstimatedReachCount = table.Column<int>(type: "integer", nullable: false, comment: "预计触达人数。"),
|
||||
ScheduleType = table.Column<int>(type: "integer", nullable: false, comment: "发送时间类型。"),
|
||||
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "定时发送时间(UTC)。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "消息状态。"),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"),
|
||||
SentCount = table.Column<int>(type: "integer", nullable: false, comment: "发送成功数量。"),
|
||||
ReadCount = table.Column<int>(type: "integer", nullable: false, comment: "已读数量。"),
|
||||
ConvertedCount = table.Column<int>(type: "integer", nullable: false, comment: "转化数量。"),
|
||||
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "Hangfire 任务 ID。"),
|
||||
LastError = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最后错误信息。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_reach_messages", x => x.Id);
|
||||
},
|
||||
comment: "会员消息触达主记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_reach_recipients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
MessageId = table.Column<long>(type: "bigint", nullable: false, comment: "消息标识。"),
|
||||
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
|
||||
Channel = table.Column<int>(type: "integer", nullable: false, comment: "触达渠道。"),
|
||||
Mobile = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "手机号快照。"),
|
||||
OpenId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "OpenId 快照。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "发送状态。"),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"),
|
||||
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "已读时间(UTC)。"),
|
||||
ConvertedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "转化时间(UTC)。"),
|
||||
ErrorMessage = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "失败摘要。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_reach_recipients", x => x.Id);
|
||||
},
|
||||
comment: "会员消息触达收件明细。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_message_templates_TenantId_Category_UsageCount",
|
||||
table: "member_message_templates",
|
||||
columns: new[] { "TenantId", "Category", "UsageCount" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_message_templates_TenantId_Name",
|
||||
table: "member_message_templates",
|
||||
columns: new[] { "TenantId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_messages_TenantId_CreatedAt",
|
||||
table: "member_reach_messages",
|
||||
columns: new[] { "TenantId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_messages_TenantId_Status_ScheduledAt",
|
||||
table: "member_reach_messages",
|
||||
columns: new[] { "TenantId", "Status", "ScheduledAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_recipients_TenantId_MessageId_MemberId_Channel",
|
||||
table: "member_reach_recipients",
|
||||
columns: new[] { "TenantId", "MessageId", "MemberId", "Channel" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_recipients_TenantId_MessageId_Status",
|
||||
table: "member_reach_recipients",
|
||||
columns: new[] { "TenantId", "MessageId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_message_templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_reach_messages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_reach_recipients");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||
|
||||
/// <summary>
|
||||
/// 写入交易流水菜单与权限定义。
|
||||
/// </summary>
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260304093000_SeedFinanceTransactionMenuAndPermissions")]
|
||||
public sealed class SeedFinanceTransactionMenuAndPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
v_parent_permission_id bigint;
|
||||
v_view_permission_id bigint;
|
||||
v_detail_permission_id bigint;
|
||||
v_export_permission_id bigint;
|
||||
v_parent_menu_id bigint;
|
||||
v_transaction_menu_id bigint;
|
||||
v_permission_seed_base bigint := 840000000000000000;
|
||||
v_menu_seed_base bigint := 850000000000000000;
|
||||
BEGIN
|
||||
-- 1. 确保财务权限分组存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'group:tenant:finance'
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_permission_id IS NULL THEN
|
||||
v_parent_permission_id := v_permission_seed_base + 1;
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_parent_permission_id, '财务中心', 'group:tenant:finance', '财务中心权限分组',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
0, 5000, 'group', 1)
|
||||
ON CONFLICT ("Code") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 2. Upsert 交易流水查看权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 11, '交易流水查看', 'tenant:finance:transaction:view', '查看交易流水列表与统计',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5010, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
-- 3. Upsert 交易流水详情权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 12, '交易流水详情', 'tenant:finance:transaction:detail', '查看交易流水详情抽屉',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5020, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
-- 4. Upsert 交易流水导出权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 13, '交易流水导出', 'tenant:finance:transaction:export', '导出交易流水 CSV',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5030, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
-- 5. 回填权限 ID。
|
||||
SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:view' LIMIT 1;
|
||||
SELECT "Id" INTO v_detail_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:detail' LIMIT 1;
|
||||
SELECT "Id" INTO v_export_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:export' LIMIT 1;
|
||||
|
||||
-- 6. 确保租户端财务父菜单存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_menu_id IS NULL THEN
|
||||
v_parent_menu_id := v_menu_seed_base + 1;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
|
||||
FALSE, NULL, FALSE, 500,
|
||||
'', '', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 7. Upsert 交易流水菜单。
|
||||
SELECT "Id"
|
||||
INTO v_transaction_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance/transaction'
|
||||
ORDER BY "DeletedAt" NULLS FIRST, "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_transaction_menu_id IS NULL THEN
|
||||
v_transaction_menu_id := v_menu_seed_base + 11;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_transaction_menu_id, v_parent_menu_id, 'TransactionFlow', '/finance/transaction', '/finance/transaction/index', '交易流水', 'lucide:receipt',
|
||||
FALSE, NULL, TRUE, 510,
|
||||
'tenant:finance:transaction:view', 'tenant:finance:transaction:view', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
ELSE
|
||||
UPDATE public.menu_definitions
|
||||
SET "ParentId" = v_parent_menu_id,
|
||||
"Name" = 'TransactionFlow',
|
||||
"Component" = '/finance/transaction/index',
|
||||
"Title" = '交易流水',
|
||||
"Icon" = 'lucide:receipt',
|
||||
"IsIframe" = FALSE,
|
||||
"Link" = NULL,
|
||||
"KeepAlive" = TRUE,
|
||||
"SortOrder" = 510,
|
||||
"RequiredPermissions" = 'tenant:finance:transaction:view',
|
||||
"MetaPermissions" = 'tenant:finance:transaction:view',
|
||||
"MetaRoles" = '',
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1
|
||||
WHERE "Id" = v_transaction_menu_id;
|
||||
END IF;
|
||||
|
||||
-- 8. 为 tenant-admin 角色直接授予新权限。
|
||||
INSERT INTO public.role_permissions (
|
||||
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('tenant-admin:' || role."Id"::text || ':' || permission_id::text, 0)),
|
||||
role."Id",
|
||||
permission_id,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
role."TenantId",
|
||||
1
|
||||
FROM public.roles role
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id, v_export_permission_id]) AS permission_id
|
||||
) item
|
||||
WHERE role."Code" = 'tenant-admin'
|
||||
AND role."DeletedAt" IS NULL
|
||||
AND item.permission_id IS NOT NULL
|
||||
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1;
|
||||
|
||||
-- 9. 将旧财务权限角色映射到新交易流水查看/详情权限。
|
||||
INSERT INTO public.role_permissions (
|
||||
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('legacy-view:' || source."RoleId"::text || ':' || item.permission_id::text, 0)),
|
||||
source."RoleId",
|
||||
item.permission_id,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
source."TenantId",
|
||||
1
|
||||
FROM (
|
||||
SELECT DISTINCT rp."RoleId", rp."TenantId"
|
||||
FROM public.role_permissions rp
|
||||
JOIN public.permissions p ON p."Id" = rp."PermissionId"
|
||||
WHERE p."Code" IN ('tenant:finance:statement:view', 'tenant:finance:income:view')
|
||||
AND rp."DeletedAt" IS NULL
|
||||
) source
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id]) AS permission_id
|
||||
) item
|
||||
WHERE item.permission_id IS NOT NULL
|
||||
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1;
|
||||
|
||||
-- 10. 将旧导出权限角色映射到新交易流水导出权限。
|
||||
INSERT INTO public.role_permissions (
|
||||
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('legacy-export:' || source."RoleId"::text || ':' || v_export_permission_id::text, 0)),
|
||||
source."RoleId",
|
||||
v_export_permission_id,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
source."TenantId",
|
||||
1
|
||||
FROM (
|
||||
SELECT DISTINCT rp."RoleId", rp."TenantId"
|
||||
FROM public.role_permissions rp
|
||||
JOIN public.permissions p ON p."Id" = rp."PermissionId"
|
||||
WHERE p."Code" IN ('tenant:finance:statement:export', 'tenant:finance:income:export')
|
||||
AND rp."DeletedAt" IS NULL
|
||||
) source
|
||||
WHERE v_export_permission_id IS NOT NULL
|
||||
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1;
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
DELETE FROM public.role_permissions
|
||||
WHERE "PermissionId" IN (
|
||||
SELECT "Id"
|
||||
FROM public.permissions
|
||||
WHERE "Code" IN (
|
||||
'tenant:finance:transaction:view',
|
||||
'tenant:finance:transaction:detail',
|
||||
'tenant:finance:transaction:export'));
|
||||
|
||||
DELETE FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance/transaction';
|
||||
|
||||
DELETE FROM public.permissions
|
||||
WHERE "Code" IN (
|
||||
'tenant:finance:transaction:view',
|
||||
'tenant:finance:transaction:detail',
|
||||
'tenant:finance:transaction:export');
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user