Compare commits

...

5 Commits

Author SHA1 Message Date
b0bb87d97c feat(finance): add tenant settlement query backend 2026-03-04 15:48:37 +08:00
39e28c1a62 Merge pull request #3 from msumshk/feature/member-points-mall-1to1
feat(member): implement points mall backend module
2026-03-04 12:32:58 +08:00
dd2ac79d48 Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
2026-03-04 12:17:33 +08:00
bd418c5927 feat(member): implement points mall backend module 2026-03-04 12:15:18 +08:00
a8cfda88f7 feat: 完成会员消息触达后端模块 2026-03-04 11:53:52 +08:00
110 changed files with 21074 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
/// <summary>
/// 到账统计请求。
/// </summary>
public sealed class FinanceSettlementStatsRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 到账筛选请求。
/// </summary>
public class FinanceSettlementFilterRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 渠道wechat/alipay
/// </summary>
public string? Channel { get; set; }
}
/// <summary>
/// 到账列表请求。
/// </summary>
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 20;
}
/// <summary>
/// 到账明细请求。
/// </summary>
public sealed class FinanceSettlementDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 到账日期yyyy-MM-dd
/// </summary>
public string ArrivedDate { get; set; } = string.Empty;
/// <summary>
/// 渠道wechat/alipay
/// </summary>
public string Channel { get; set; } = string.Empty;
}
/// <summary>
/// 到账统计响应。
/// </summary>
public sealed class FinanceSettlementStatsResponse
{
/// <summary>
/// 今日到账。
/// </summary>
public decimal TodayArrivedAmount { get; set; }
/// <summary>
/// 昨日到账。
/// </summary>
public decimal YesterdayArrivedAmount { get; set; }
/// <summary>
/// 本月到账。
/// </summary>
public decimal CurrentMonthArrivedAmount { get; set; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public int CurrentMonthTransactionCount { get; set; }
}
/// <summary>
/// 到账账户信息响应。
/// </summary>
public sealed class FinanceSettlementAccountResponse
{
/// <summary>
/// 银行名称。
/// </summary>
public string BankName { get; set; } = string.Empty;
/// <summary>
/// 开户名。
/// </summary>
public string BankAccountName { get; set; } = string.Empty;
/// <summary>
/// 脱敏银行账号。
/// </summary>
public string BankAccountNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏微信商户号。
/// </summary>
public string WechatMerchantNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏支付宝 PID。
/// </summary>
public string AlipayPidMasked { get; set; } = string.Empty;
/// <summary>
/// 结算周期文案。
/// </summary>
public string SettlementPeriodText { get; set; } = string.Empty;
}
/// <summary>
/// 到账列表行响应。
/// </summary>
public sealed class FinanceSettlementListItemResponse
{
/// <summary>
/// 到账日期。
/// </summary>
public string ArrivedDate { get; set; } = string.Empty;
/// <summary>
/// 渠道编码。
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 渠道文案。
/// </summary>
public string ChannelText { get; set; } = string.Empty;
/// <summary>
/// 交易笔数。
/// </summary>
public int TransactionCount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
}
/// <summary>
/// 到账列表响应。
/// </summary>
public sealed class FinanceSettlementListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceSettlementListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 到账明细行响应。
/// </summary>
public sealed class FinanceSettlementDetailItemResponse
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 支付时间。
/// </summary>
public string PaidAt { get; set; } = string.Empty;
}
/// <summary>
/// 到账明细响应。
/// </summary>
public sealed class FinanceSettlementDetailResultResponse
{
/// <summary>
/// 明细列表。
/// </summary>
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
}
/// <summary>
/// 到账导出响应。
/// </summary>
public sealed class FinanceSettlementExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件内容Base64
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,808 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 积分商城规则详情查询请求。
/// </summary>
public sealed class PointMallRuleDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 保存积分商城规则请求。
/// </summary>
public sealed class SavePointMallRuleRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否启用消费获取。
/// </summary>
public bool IsConsumeRewardEnabled { get; set; }
/// <summary>
/// 每消费多少元触发一次积分计算。
/// </summary>
public int ConsumeAmountPerStep { get; set; }
/// <summary>
/// 每步获得积分。
/// </summary>
public int ConsumeRewardPointsPerStep { get; set; }
/// <summary>
/// 是否启用评价奖励。
/// </summary>
public bool IsReviewRewardEnabled { get; set; }
/// <summary>
/// 评价奖励积分。
/// </summary>
public int ReviewRewardPoints { get; set; }
/// <summary>
/// 是否启用注册奖励。
/// </summary>
public bool IsRegisterRewardEnabled { get; set; }
/// <summary>
/// 注册奖励积分。
/// </summary>
public int RegisterRewardPoints { get; set; }
/// <summary>
/// 是否启用签到奖励。
/// </summary>
public bool IsSigninRewardEnabled { get; set; }
/// <summary>
/// 签到奖励积分。
/// </summary>
public int SigninRewardPoints { get; set; }
/// <summary>
/// 有效期模式permanent/yearly_clear
/// </summary>
public string ExpiryMode { get; set; } = "yearly_clear";
}
/// <summary>
/// 积分商城商品列表查询请求。
/// </summary>
public sealed class PointMallProductListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled可空
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 积分商城商品详情查询请求。
/// </summary>
public sealed class PointMallProductDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
}
/// <summary>
/// 保存积分商城商品请求。
/// </summary>
public sealed class SavePointMallProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID编辑时传
/// </summary>
public string? PointMallProductId { get; set; }
/// <summary>
/// 展示名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 展示图片。
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string RedeemType { get; set; } = "product";
/// <summary>
/// 关联商品 ID。
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// 关联优惠券模板 ID。
/// </summary>
public string? CouponTemplateId { get; set; }
/// <summary>
/// 实物名称。
/// </summary>
public string? PhysicalName { get; set; }
/// <summary>
/// 领取方式store_pickup/delivery
/// </summary>
public string? PickupMethod { get; set; }
/// <summary>
/// 商品描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 兑换方式points/mixed
/// </summary>
public string ExchangeType { get; set; } = "points";
/// <summary>
/// 所需积分。
/// </summary>
public int RequiredPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 库存总量。
/// </summary>
public int StockTotal { get; set; }
/// <summary>
/// 每人限兑次数。
/// </summary>
public int? PerMemberLimit { get; set; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 修改积分商城商品状态请求。
/// </summary>
public sealed class ChangePointMallProductStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "disabled";
}
/// <summary>
/// 删除积分商城商品请求。
/// </summary>
public sealed class DeletePointMallProductRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
}
/// <summary>
/// 积分商城兑换记录分页查询请求。
/// </summary>
public sealed class PointMallRecordListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string? RedeemType { get; set; }
/// <summary>
/// 状态pending_pickup/issued/completed/canceled
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 积分商城兑换记录详情请求。
/// </summary>
public sealed class PointMallRecordDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 兑换记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
}
/// <summary>
/// 导出积分商城兑换记录请求。
/// </summary>
public sealed class ExportPointMallRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string? RedeemType { get; set; }
/// <summary>
/// 状态pending_pickup/issued/completed/canceled
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 写入积分商城兑换记录请求。
/// </summary>
public sealed class WritePointMallRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
/// <summary>
/// 会员 ID。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 兑换时间(可空,默认当前时间)。
/// </summary>
public DateTime? RedeemedAt { get; set; }
}
/// <summary>
/// 核销积分商城兑换记录请求。
/// </summary>
public sealed class VerifyPointMallRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 兑换记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 核销方式scan/manual
/// </summary>
public string VerifyMethod { get; set; } = "manual";
/// <summary>
/// 核销备注。
/// </summary>
public string? VerifyRemark { get; set; }
}
/// <summary>
/// 积分商城规则响应。
/// </summary>
public sealed class PointMallRuleResponse
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 是否启用消费获取。
/// </summary>
public bool IsConsumeRewardEnabled { get; set; }
/// <summary>
/// 每消费多少元触发一次积分计算。
/// </summary>
public int ConsumeAmountPerStep { get; set; }
/// <summary>
/// 每步获得积分。
/// </summary>
public int ConsumeRewardPointsPerStep { get; set; }
/// <summary>
/// 是否启用评价奖励。
/// </summary>
public bool IsReviewRewardEnabled { get; set; }
/// <summary>
/// 评价奖励积分。
/// </summary>
public int ReviewRewardPoints { get; set; }
/// <summary>
/// 是否启用注册奖励。
/// </summary>
public bool IsRegisterRewardEnabled { get; set; }
/// <summary>
/// 注册奖励积分。
/// </summary>
public int RegisterRewardPoints { get; set; }
/// <summary>
/// 是否启用签到奖励。
/// </summary>
public bool IsSigninRewardEnabled { get; set; }
/// <summary>
/// 签到奖励积分。
/// </summary>
public int SigninRewardPoints { get; set; }
/// <summary>
/// 有效期模式permanent/yearly_clear
/// </summary>
public string ExpiryMode { get; set; } = "yearly_clear";
}
/// <summary>
/// 积分商城规则统计响应。
/// </summary>
public sealed class PointMallRuleStatsResponse
{
/// <summary>
/// 累计发放积分。
/// </summary>
public int TotalIssuedPoints { get; set; }
/// <summary>
/// 已兑换积分。
/// </summary>
public int RedeemedPoints { get; set; }
/// <summary>
/// 积分用户。
/// </summary>
public int PointMembers { get; set; }
/// <summary>
/// 兑换率0-100
/// </summary>
public decimal RedeemRate { get; set; }
}
/// <summary>
/// 积分商城规则详情响应。
/// </summary>
public sealed class PointMallRuleDetailResultResponse
{
/// <summary>
/// 规则。
/// </summary>
public PointMallRuleResponse Rule { get; set; } = new();
/// <summary>
/// 统计。
/// </summary>
public PointMallRuleStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 积分商城商品响应。
/// </summary>
public sealed class PointMallProductResponse
{
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 展示图片。
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string RedeemType { get; set; } = "product";
/// <summary>
/// 兑换类型文案。
/// </summary>
public string RedeemTypeText { get; set; } = "商品";
/// <summary>
/// 关联商品 ID。
/// </summary>
public string? ProductId { get; set; }
/// <summary>
/// 关联优惠券模板 ID。
/// </summary>
public string? CouponTemplateId { get; set; }
/// <summary>
/// 实物名称。
/// </summary>
public string? PhysicalName { get; set; }
/// <summary>
/// 领取方式store_pickup/delivery
/// </summary>
public string? PickupMethod { get; set; }
/// <summary>
/// 商品描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 兑换方式points/mixed
/// </summary>
public string ExchangeType { get; set; } = "points";
/// <summary>
/// 所需积分。
/// </summary>
public int RequiredPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 初始库存。
/// </summary>
public int StockTotal { get; set; }
/// <summary>
/// 剩余库存。
/// </summary>
public int StockAvailable { get; set; }
/// <summary>
/// 已兑换数量。
/// </summary>
public int RedeemedCount { get; set; }
/// <summary>
/// 每人限兑次数。
/// </summary>
public int? PerMemberLimit { get; set; }
/// <summary>
/// 通知渠道。
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = "上架";
/// <summary>
/// 更新时间。
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 积分商城商品列表响应。
/// </summary>
public sealed class PointMallProductListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PointMallProductResponse> Items { get; set; } = [];
}
/// <summary>
/// 积分商城兑换记录响应。
/// </summary>
public class PointMallRecordResponse
{
/// <summary>
/// 兑换记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 兑换单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 积分商城商品 ID。
/// </summary>
public string PointMallProductId { get; set; } = string.Empty;
/// <summary>
/// 商品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 兑换类型product/coupon/physical
/// </summary>
public string RedeemType { get; set; } = "product";
/// <summary>
/// 兑换类型文案。
/// </summary>
public string RedeemTypeText { get; set; } = "商品";
/// <summary>
/// 兑换方式points/mixed
/// </summary>
public string ExchangeType { get; set; } = "points";
/// <summary>
/// 会员 ID。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberMobileMasked { get; set; } = string.Empty;
/// <summary>
/// 消耗积分。
/// </summary>
public int UsedPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 状态pending_pickup/issued/completed/canceled
/// </summary>
public string Status { get; set; } = "issued";
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = "已发放";
/// <summary>
/// 兑换时间。
/// </summary>
public string RedeemedAt { get; set; } = string.Empty;
/// <summary>
/// 发放时间。
/// </summary>
public string? IssuedAt { get; set; }
/// <summary>
/// 核销时间。
/// </summary>
public string? VerifiedAt { get; set; }
}
/// <summary>
/// 积分商城兑换记录详情响应。
/// </summary>
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
{
/// <summary>
/// 核销方式scan/manual
/// </summary>
public string? VerifyMethod { get; set; }
/// <summary>
/// 核销方式文案。
/// </summary>
public string? VerifyMethodText { get; set; }
/// <summary>
/// 核销备注。
/// </summary>
public string? VerifyRemark { get; set; }
/// <summary>
/// 核销人 ID。
/// </summary>
public string? VerifiedBy { get; set; }
}
/// <summary>
/// 积分商城兑换记录统计响应。
/// </summary>
public sealed class PointMallRecordStatsResponse
{
/// <summary>
/// 今日兑换。
/// </summary>
public int TodayRedeemCount { get; set; }
/// <summary>
/// 待领取实物。
/// </summary>
public int PendingPhysicalCount { get; set; }
/// <summary>
/// 本月消耗积分。
/// </summary>
public int CurrentMonthUsedPoints { get; set; }
}
/// <summary>
/// 积分商城兑换记录分页响应。
/// </summary>
public sealed class PointMallRecordListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PointMallRecordResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public PointMallRecordStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 积分商城兑换记录导出响应。
/// </summary>
public sealed class PointMallRecordExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 文件内容。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,262 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Finance;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 财务中心到账查询。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/finance/settlement")]
public sealed class FinanceSettlementController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
private const string ViewPermission = "tenant:finance:settlement:view";
private const string ExportPermission = "tenant:finance:settlement:export";
/// <summary>
/// 查询到账统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
[FromQuery] FinanceSettlementStatsRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
{
TodayArrivedAmount = stats.TodayArrivedAmount,
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
});
}
/// <summary>
/// 查询到账账户信息。
/// </summary>
[HttpGet("account")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
CancellationToken cancellationToken)
{
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
if (account is null)
{
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
}
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
{
BankName = account.BankName,
BankAccountName = account.BankAccountName,
BankAccountNoMasked = account.BankAccountNoMasked,
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
AlipayPidMasked = account.AlipayPidMasked,
SettlementPeriodText = account.SettlementPeriodText
});
}
/// <summary>
/// 查询到账汇总列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
[FromQuery] FinanceSettlementListRequest request,
CancellationToken cancellationToken)
{
var parsed = await ParseFilterAsync(request, cancellationToken);
var result = await mediator.Send(new SearchFinanceSettlementListQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
PaymentMethod = parsed.PaymentMethod,
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.Total,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 查询到账明细(展开行)。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
[FromQuery] FinanceSettlementDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
{
StoreId = storeId,
ArrivedDate = arrivedDate,
PaymentMethod = paymentMethod,
Take = 50
}, cancellationToken);
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
{
Items = result.Items.Select(MapDetailItem).ToList()
});
}
/// <summary>
/// 导出到账汇总 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ExportPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
[FromQuery] FinanceSettlementFilterRequest request,
CancellationToken cancellationToken)
{
var parsed = await ParseFilterAsync(request, cancellationToken);
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
PaymentMethod = parsed.PaymentMethod
}, cancellationToken);
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
FinanceSettlementFilterRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var startAt = ParseDateOrNull(request.StartDate);
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
{
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
}
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime ParseRequiredDate(string? value, string parameterName)
{
return ParseDateOrNull(value)
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
}
private static DateTime? ParseDateOrNull(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTime.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
}
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
}
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
{
return ParseOptionalSettlementChannel(channel)
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
}
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
{
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
{
"wechat" => PaymentMethod.WeChatPay,
"alipay" => PaymentMethod.Alipay,
"" => null,
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
};
}
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
{
return new FinanceSettlementListItemResponse
{
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Channel = source.Channel,
ChannelText = source.ChannelText,
TransactionCount = source.TransactionCount,
ArrivedAmount = source.ArrivedAmount
};
}
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
{
return new FinanceSettlementDetailItemResponse
{
OrderNo = source.OrderNo,
Amount = source.Amount,
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,526 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Member;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 会员中心积分商城管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/member/points-mall")]
public sealed class MemberPointsMallController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:member:points-mall:view";
private const string ManagePermission = "tenant:member:points-mall:manage";
/// <summary>
/// 获取积分规则详情。
/// </summary>
[HttpGet("rule/detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> RuleDetail(
[FromQuery] PointMallRuleDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPointMallRuleDetailQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<PointMallRuleDetailResultResponse>.Ok(new PointMallRuleDetailResultResponse
{
Rule = MapRule(result.Rule),
Stats = new PointMallRuleStatsResponse
{
TotalIssuedPoints = result.Stats.TotalIssuedPoints,
RedeemedPoints = result.Stats.RedeemedPoints,
PointMembers = result.Stats.PointMembers,
RedeemRate = result.Stats.RedeemRate
}
});
}
/// <summary>
/// 保存积分规则。
/// </summary>
[HttpPost("rule/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRuleResponse>> SaveRule(
[FromBody] SavePointMallRuleRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SavePointMallRuleCommand
{
StoreId = storeId,
IsConsumeRewardEnabled = request.IsConsumeRewardEnabled,
ConsumeAmountPerStep = request.ConsumeAmountPerStep,
ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep,
IsReviewRewardEnabled = request.IsReviewRewardEnabled,
ReviewRewardPoints = request.ReviewRewardPoints,
IsRegisterRewardEnabled = request.IsRegisterRewardEnabled,
RegisterRewardPoints = request.RegisterRewardPoints,
IsSigninRewardEnabled = request.IsSigninRewardEnabled,
SigninRewardPoints = request.SigninRewardPoints,
ExpiryMode = request.ExpiryMode
}, cancellationToken);
return ApiResponse<PointMallRuleResponse>.Ok(MapRule(result));
}
/// <summary>
/// 查询兑换商品列表。
/// </summary>
[HttpGet("product/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallProductListResultResponse>> ProductList(
[FromQuery] PointMallProductListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPointMallProductListQuery
{
StoreId = storeId,
Status = request.Status,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
{
Items = result.Items.Select(MapProduct).ToList()
});
}
/// <summary>
/// 查询兑换商品详情。
/// </summary>
[HttpGet("product/detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallProductResponse>> ProductDetail(
[FromQuery] PointMallProductDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPointMallProductDetailQuery
{
StoreId = storeId,
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
}, cancellationToken);
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
}
/// <summary>
/// 保存兑换商品。
/// </summary>
[HttpPost("product/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallProductResponse>> SaveProduct(
[FromBody] SavePointMallProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SavePointMallProductCommand
{
StoreId = storeId,
PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId),
Name = request.Name,
ImageUrl = request.ImageUrl,
RedeemType = request.RedeemType,
ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId),
CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId),
PhysicalName = request.PhysicalName,
PickupMethod = request.PickupMethod,
Description = request.Description,
ExchangeType = request.ExchangeType,
RequiredPoints = request.RequiredPoints,
CashAmount = request.CashAmount,
StockTotal = request.StockTotal,
PerMemberLimit = request.PerMemberLimit,
NotifyChannels = request.NotifyChannels,
Status = request.Status
}, cancellationToken);
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
}
/// <summary>
/// 修改兑换商品状态。
/// </summary>
[HttpPost("product/status")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallProductResponse>> ChangeProductStatus(
[FromBody] ChangePointMallProductStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangePointMallProductStatusCommand
{
StoreId = storeId,
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
}
/// <summary>
/// 删除兑换商品。
/// </summary>
[HttpPost("product/delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteProduct(
[FromBody] DeletePointMallProductRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeletePointMallProductCommand
{
StoreId = storeId,
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 查询兑换记录分页。
/// </summary>
[HttpGet("record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRecordListResultResponse>> RecordList(
[FromQuery] PointMallRecordListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPointMallRecordListQuery
{
StoreId = storeId,
RedeemType = request.RedeemType,
Status = request.Status,
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<PointMallRecordListResultResponse>.Ok(new PointMallRecordListResultResponse
{
Items = result.Items.Select(MapRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = new PointMallRecordStatsResponse
{
TodayRedeemCount = result.Stats.TodayRedeemCount,
PendingPhysicalCount = result.Stats.PendingPhysicalCount,
CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints
}
});
}
/// <summary>
/// 查询兑换记录详情。
/// </summary>
[HttpGet("record/detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRecordDetailResponse>> RecordDetail(
[FromQuery] PointMallRecordDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPointMallRecordDetailQuery
{
StoreId = storeId,
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
}, cancellationToken);
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
}
/// <summary>
/// 导出兑换记录 CSV。
/// </summary>
[HttpGet("record/export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRecordExportResponse>> ExportRecord(
[FromQuery] ExportPointMallRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ExportPointMallRecordCsvQuery
{
StoreId = storeId,
RedeemType = request.RedeemType,
Status = request.Status,
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 写入兑换记录。
/// </summary>
[HttpPost("record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRecordResponse>> WriteRecord(
[FromBody] WritePointMallRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new WritePointMallRecordCommand
{
StoreId = storeId,
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
RedeemedAt = request.RedeemedAt
}, cancellationToken);
return ApiResponse<PointMallRecordResponse>.Ok(MapRecord(result));
}
/// <summary>
/// 核销兑换记录。
/// </summary>
[HttpPost("record/verify")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PointMallRecordDetailResponse>> VerifyRecord(
[FromBody] VerifyPointMallRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new VerifyPointMallRecordCommand
{
StoreId = storeId,
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
VerifyMethod = request.VerifyMethod,
VerifyRemark = request.VerifyRemark
}, cancellationToken);
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime? ParseDateOrNull(string? value, string fieldName)
{
return string.IsNullOrWhiteSpace(value)
? null
: StoreApiHelpers.ParseDateOnly(value, fieldName);
}
private static PointMallRuleResponse MapRule(MemberPointMallRuleDto source)
{
return new PointMallRuleResponse
{
StoreId = source.StoreId.ToString(),
IsConsumeRewardEnabled = source.IsConsumeRewardEnabled,
ConsumeAmountPerStep = source.ConsumeAmountPerStep,
ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep,
IsReviewRewardEnabled = source.IsReviewRewardEnabled,
ReviewRewardPoints = source.ReviewRewardPoints,
IsRegisterRewardEnabled = source.IsRegisterRewardEnabled,
RegisterRewardPoints = source.RegisterRewardPoints,
IsSigninRewardEnabled = source.IsSigninRewardEnabled,
SigninRewardPoints = source.SigninRewardPoints,
ExpiryMode = source.ExpiryMode
};
}
private static PointMallProductResponse MapProduct(MemberPointMallProductDto source)
{
return new PointMallProductResponse
{
PointMallProductId = source.PointMallProductId.ToString(),
StoreId = source.StoreId.ToString(),
Name = source.Name,
ImageUrl = source.ImageUrl,
RedeemType = source.RedeemType,
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
ProductId = source.ProductId?.ToString(),
CouponTemplateId = source.CouponTemplateId?.ToString(),
PhysicalName = source.PhysicalName,
PickupMethod = source.PickupMethod,
Description = source.Description,
ExchangeType = source.ExchangeType,
RequiredPoints = source.RequiredPoints,
CashAmount = source.CashAmount,
StockTotal = source.StockTotal,
StockAvailable = source.StockAvailable,
RedeemedCount = source.RedeemedCount,
PerMemberLimit = source.PerMemberLimit,
NotifyChannels = source.NotifyChannels.ToList(),
Status = source.Status,
StatusText = ResolveProductStatusText(source.Status),
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
}
private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source)
{
return new PointMallRecordResponse
{
RecordId = source.RecordId.ToString(),
RecordNo = source.RecordNo,
PointMallProductId = source.PointMallProductId.ToString(),
ProductName = source.ProductName,
RedeemType = source.RedeemType,
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
ExchangeType = source.ExchangeType,
MemberId = source.MemberId.ToString(),
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
UsedPoints = source.UsedPoints,
CashAmount = source.CashAmount,
Status = source.Status,
StatusText = ResolveRecordStatusText(source.Status),
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
}
private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source)
{
var response = new PointMallRecordDetailResponse
{
RecordId = source.RecordId.ToString(),
RecordNo = source.RecordNo,
PointMallProductId = source.PointMallProductId.ToString(),
ProductName = source.ProductName,
RedeemType = source.RedeemType,
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
ExchangeType = source.ExchangeType,
MemberId = source.MemberId.ToString(),
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
UsedPoints = source.UsedPoints,
CashAmount = source.CashAmount,
Status = source.Status,
StatusText = ResolveRecordStatusText(source.Status),
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
VerifyMethod = source.VerifyMethod,
VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod),
VerifyRemark = source.VerifyRemark,
VerifiedBy = source.VerifiedBy?.ToString()
};
return response;
}
private static string ResolveRedeemTypeText(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"product" => "商品",
"coupon" => "优惠券",
"physical" => "实物",
_ => "未知"
};
}
private static string ResolveProductStatusText(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"enabled" => "上架",
"disabled" => "下架",
_ => "未知"
};
}
private static string ResolveRecordStatusText(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"pending_pickup" => "待领取",
"issued" => "已发放",
"completed" => "已完成",
"canceled" => "已取消",
_ => "未知"
};
}
private static string? ResolveVerifyMethodText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant() switch
{
"scan" => "扫码核销",
"manual" => "手动核销",
_ => "未知"
};
}
}

View File

@@ -10,9 +10,12 @@ using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
using TakeoutSaaS.Application.App.Common.Geo; using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Extensions; 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.Dictionary.Extensions;
using TakeoutSaaS.Application.Identity.Extensions; using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Application.Messaging.Extensions; using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Application.Sms.Extensions;
using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions; using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Infrastructure.Dictionary.Extensions; using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
@@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions; using TakeoutSaaS.Module.Messaging.Extensions;
using TakeoutSaaS.Module.Messaging.Options; using TakeoutSaaS.Module.Messaging.Options;
using TakeoutSaaS.Module.Scheduler.Extensions; using TakeoutSaaS.Module.Scheduler.Extensions;
using TakeoutSaaS.Module.Sms.Extensions;
using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Storage.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Security;
@@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn))
// 6. 注册应用层与基础设施(仅租户侧所需) // 6. 注册应用层与基础设施(仅租户侧所需)
builder.Services.AddAppApplication(); builder.Services.AddAppApplication();
builder.Services.AddSmsApplication(builder.Configuration);
builder.Services.AddIdentityApplication(enableMiniSupport: false); builder.Services.AddIdentityApplication(enableMiniSupport: false);
builder.Services.AddAppInfrastructure(builder.Configuration); builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
@@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现) // 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
builder.Services.AddMessagingApplication(); builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddSmsModule(builder.Configuration);
builder.Services.AddMassTransit(configurator => builder.Services.AddMassTransit(configurator =>
{ {
// 注册 SignalR 推送消费者 // 注册 SignalR 推送消费者
@@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator =>
builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStorageApplication(); builder.Services.AddStorageApplication();
builder.Services.AddSchedulerModule(builder.Configuration); 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 注册腾讯地图地理编码服务(服务端签名) // 9.1 注册腾讯地图地理编码服务(服务端签名)
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName)); builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));

View File

@@ -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);
}

View File

@@ -125,6 +125,49 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken" "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": { "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", "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, "WorkerCount": 10,

View File

@@ -123,6 +123,49 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken" "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": { "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", "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, "WorkerCount": 10,

View File

@@ -3,6 +3,7 @@ using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Reflection; using System.Reflection;
using TakeoutSaaS.Application.App.Common.Behaviors; using TakeoutSaaS.Application.App.Common.Behaviors;
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
using TakeoutSaaS.Application.App.Personal.Services; using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Application.App.Personal.Validators; using TakeoutSaaS.Application.App.Personal.Validators;
using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Application.App.Stores.Services;
@@ -35,6 +36,9 @@ public static class AppApplicationServiceCollectionExtensions
// 2. 注册门店模块上下文服务 // 2. 注册门店模块上下文服务
services.AddScoped<StoreContextService>(); services.AddScoped<StoreContextService>();
// 3. (空行后) 注册会员消息触达服务
services.AddScoped<IMemberMessageReachAppService, MemberMessageReachAppService>();
return services; return services;
} }
} }

View File

@@ -0,0 +1,173 @@
namespace TakeoutSaaS.Application.App.Finance.Settlement.Dto;
/// <summary>
/// 到账查询汇总行 DTO。
/// </summary>
public sealed class FinanceSettlementListItemDto
{
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public DateTime ArrivedDate { get; set; }
/// <summary>
/// 渠道编码wechat/alipay
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 渠道文案。
/// </summary>
public string ChannelText { get; set; } = string.Empty;
/// <summary>
/// 交易笔数。
/// </summary>
public int TransactionCount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
}
/// <summary>
/// 到账查询分页结果 DTO。
/// </summary>
public sealed class FinanceSettlementListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceSettlementListItemDto> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 当前页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 到账明细行 DTO。
/// </summary>
public sealed class FinanceSettlementDetailItemDto
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime PaidAt { get; set; }
}
/// <summary>
/// 到账明细结果 DTO。
/// </summary>
public sealed class FinanceSettlementDetailResultDto
{
/// <summary>
/// 明细列表。
/// </summary>
public List<FinanceSettlementDetailItemDto> Items { get; set; } = [];
}
/// <summary>
/// 到账统计 DTO。
/// </summary>
public sealed class FinanceSettlementStatsDto
{
/// <summary>
/// 今日到账金额。
/// </summary>
public decimal TodayArrivedAmount { get; set; }
/// <summary>
/// 昨日到账金额。
/// </summary>
public decimal YesterdayArrivedAmount { get; set; }
/// <summary>
/// 本月到账金额。
/// </summary>
public decimal CurrentMonthArrivedAmount { get; set; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public int CurrentMonthTransactionCount { get; set; }
}
/// <summary>
/// 到账账户信息 DTO。
/// </summary>
public sealed class FinanceSettlementAccountDto
{
/// <summary>
/// 银行名称。
/// </summary>
public string BankName { get; set; } = string.Empty;
/// <summary>
/// 开户名。
/// </summary>
public string BankAccountName { get; set; } = string.Empty;
/// <summary>
/// 脱敏银行账号。
/// </summary>
public string BankAccountNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏微信商户号。
/// </summary>
public string WechatMerchantNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏支付宝 PID。
/// </summary>
public string AlipayPidMasked { get; set; } = string.Empty;
/// <summary>
/// 结算周期文案。
/// </summary>
public string SettlementPeriodText { get; set; } = string.Empty;
}
/// <summary>
/// 到账导出 DTO。
/// </summary>
public sealed class FinanceSettlementExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,71 @@
using System.Globalization;
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账汇总导出处理器。
/// </summary>
public sealed class ExportFinanceSettlementCsvQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportFinanceSettlementCsvQuery, FinanceSettlementExportDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementExportDto> Handle(
ExportFinanceSettlementCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rows = await financeTransactionRepository.ListSettlementForExportAsync(
tenantId,
request.StoreId,
request.StartAt,
request.EndAt,
request.PaymentMethod,
cancellationToken);
var list = rows.Select(FinanceSettlementMapping.ToListItem).ToList();
var csv = BuildCsv(list);
return new FinanceSettlementExportDto
{
FileName = $"settlement-{request.StoreId}-{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(csv)),
TotalCount = list.Count
};
}
private static string BuildCsv(IReadOnlyList<FinanceSettlementListItemDto> rows)
{
var sb = new StringBuilder();
sb.Append('\uFEFF');
sb.AppendLine("到账日期,支付渠道,交易笔数,到账金额");
foreach (var row in rows)
{
sb.AppendLine(string.Join(',',
Escape(row.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(row.ChannelText),
Escape(row.TransactionCount.ToString(CultureInfo.InvariantCulture)),
Escape(FinanceSettlementMapping.FormatAmount(row.ArrivedAmount))));
}
return sb.ToString();
}
private static string Escape(string? value)
{
var normalized = value ?? string.Empty;
if (normalized.Contains(',') || normalized.Contains('"') || normalized.Contains('\n'))
{
return $"\"{normalized.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}
return normalized;
}
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账查询映射辅助。
/// </summary>
internal static class FinanceSettlementMapping
{
/// <summary>
/// 支付方式转渠道编码。
/// </summary>
public static string ToChannelCode(PaymentMethod paymentMethod)
{
return paymentMethod switch
{
PaymentMethod.WeChatPay => "wechat",
PaymentMethod.Alipay => "alipay",
_ => "unknown"
};
}
/// <summary>
/// 支付方式转渠道文案。
/// </summary>
public static string ToChannelText(PaymentMethod paymentMethod)
{
return paymentMethod switch
{
PaymentMethod.WeChatPay => "微信支付",
PaymentMethod.Alipay => "支付宝",
_ => "未知渠道"
};
}
/// <summary>
/// 映射到账汇总行。
/// </summary>
public static FinanceSettlementListItemDto ToListItem(FinanceSettlementListItemSnapshot source)
{
return new FinanceSettlementListItemDto
{
ArrivedDate = source.ArrivedDate,
Channel = ToChannelCode(source.PaymentMethod),
ChannelText = ToChannelText(source.PaymentMethod),
TransactionCount = source.TransactionCount,
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero)
};
}
/// <summary>
/// 映射到账明细行。
/// </summary>
public static FinanceSettlementDetailItemDto ToDetailItem(FinanceSettlementDetailItemSnapshot source)
{
return new FinanceSettlementDetailItemDto
{
OrderNo = source.OrderNo,
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
PaidAt = source.PaidAt
};
}
/// <summary>
/// 格式化金额(导出场景)。
/// </summary>
public static string FormatAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero)
.ToString("0.00", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账账户信息查询处理器。
/// </summary>
public sealed class GetFinanceSettlementAccountQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementAccountQuery, FinanceSettlementAccountDto?>
{
/// <inheritdoc />
public async Task<FinanceSettlementAccountDto?> Handle(
GetFinanceSettlementAccountQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var snapshot = await financeTransactionRepository.GetSettlementAccountAsync(
tenantId,
cancellationToken);
if (snapshot is null)
{
return null;
}
return new FinanceSettlementAccountDto
{
BankName = snapshot.BankName,
BankAccountName = snapshot.BankAccountName,
BankAccountNoMasked = snapshot.BankAccountNoMasked,
WechatMerchantNoMasked = snapshot.WechatMerchantNoMasked,
AlipayPidMasked = snapshot.AlipayPidMasked,
SettlementPeriodText = snapshot.SettlementPeriodText
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账明细查询处理器。
/// </summary>
public sealed class GetFinanceSettlementDetailQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementDetailQuery, FinanceSettlementDetailResultDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementDetailResultDto> Handle(
GetFinanceSettlementDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rows = await financeTransactionRepository.GetSettlementDetailsAsync(
tenantId,
request.StoreId,
request.ArrivedDate,
request.PaymentMethod,
request.Take,
cancellationToken);
return new FinanceSettlementDetailResultDto
{
Items = rows.Select(FinanceSettlementMapping.ToDetailItem).ToList()
};
}
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账统计查询处理器。
/// </summary>
public sealed class GetFinanceSettlementStatsQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementStatsQuery, FinanceSettlementStatsDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementStatsDto> Handle(
GetFinanceSettlementStatsQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var snapshot = await financeTransactionRepository.GetSettlementStatsAsync(
tenantId,
request.StoreId,
DateTime.UtcNow,
cancellationToken);
return new FinanceSettlementStatsDto
{
TodayArrivedAmount = snapshot.TodayArrivedAmount,
YesterdayArrivedAmount = snapshot.YesterdayArrivedAmount,
CurrentMonthArrivedAmount = snapshot.CurrentMonthArrivedAmount,
CurrentMonthTransactionCount = snapshot.CurrentMonthTransactionCount
};
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账汇总分页查询处理器。
/// </summary>
public sealed class SearchFinanceSettlementListQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchFinanceSettlementListQuery, FinanceSettlementListResultDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementListResultDto> Handle(
SearchFinanceSettlementListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.Page);
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
var snapshot = await financeTransactionRepository.SearchSettlementPageAsync(
tenantId,
request.StoreId,
request.StartAt,
request.EndAt,
request.PaymentMethod,
normalizedPage,
normalizedPageSize,
cancellationToken);
return new FinanceSettlementListResultDto
{
Items = snapshot.Items.Select(FinanceSettlementMapping.ToListItem).ToList(),
Total = snapshot.TotalCount,
Page = normalizedPage,
PageSize = normalizedPageSize
};
}
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 导出到账汇总 CSV。
/// </summary>
public sealed class ExportFinanceSettlementCsvQuery : IRequest<FinanceSettlementExportDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始时间UTC闭区间
/// </summary>
public DateTime? StartAt { get; init; }
/// <summary>
/// 结束时间UTC开区间
/// </summary>
public DateTime? EndAt { get; init; }
/// <summary>
/// 支付方式筛选。
/// </summary>
public PaymentMethod? PaymentMethod { get; init; }
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账账户信息。
/// </summary>
public sealed class GetFinanceSettlementAccountQuery : IRequest<FinanceSettlementAccountDto?>
{
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账明细。
/// </summary>
public sealed class GetFinanceSettlementDetailQuery : IRequest<FinanceSettlementDetailResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public DateTime ArrivedDate { get; init; }
/// <summary>
/// 渠道(微信/支付宝)。
/// </summary>
public PaymentMethod PaymentMethod { get; init; }
/// <summary>
/// 限制条数。
/// </summary>
public int Take { get; init; } = 20;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账统计。
/// </summary>
public sealed class GetFinanceSettlementStatsQuery : IRequest<FinanceSettlementStatsDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账汇总分页。
/// </summary>
public sealed class SearchFinanceSettlementListQuery : IRequest<FinanceSettlementListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始时间UTC闭区间
/// </summary>
public DateTime? StartAt { get; init; }
/// <summary>
/// 结束时间UTC开区间
/// </summary>
public DateTime? EndAt { get; init; }
/// <summary>
/// 支付方式筛选。
/// </summary>
public PaymentMethod? PaymentMethod { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -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; } = [];
}

View File

@@ -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
};
}
}

View File

@@ -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();
}

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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];
}
}

View File

@@ -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; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,16 @@ public sealed record SubmitTenantVerificationCommand : IRequest<TenantVerificati
/// </summary> /// </summary>
public string? BankName { get; init; } public string? BankName { get; init; }
/// <summary>
/// 微信商户号。
/// </summary>
public string? WeChatMerchantNo { get; init; }
/// <summary>
/// 支付宝 PID。
/// </summary>
public string? AlipayPid { get; init; }
/// <summary> /// <summary>
/// 其他补充资料 JSON。 /// 其他补充资料 JSON。
/// </summary> /// </summary>

View File

@@ -71,6 +71,16 @@ public sealed class TenantVerificationDto
/// </summary> /// </summary>
public string? BankName { get; init; } public string? BankName { get; init; }
/// <summary>
/// 微信商户号。
/// </summary>
public string? WeChatMerchantNo { get; init; }
/// <summary>
/// 支付宝 PID。
/// </summary>
public string? AlipayPid { get; init; }
/// <summary> /// <summary>
/// 附加资料JSON /// 附加资料JSON
/// </summary> /// </summary>

View File

@@ -54,6 +54,8 @@ public sealed class SubmitTenantVerificationCommandHandler(
profile.BankAccountName = request.BankAccountName; profile.BankAccountName = request.BankAccountName;
profile.BankAccountNumber = request.BankAccountNumber; profile.BankAccountNumber = request.BankAccountNumber;
profile.BankName = request.BankName; profile.BankName = request.BankName;
profile.WeChatMerchantNo = request.WeChatMerchantNo;
profile.AlipayPid = request.AlipayPid;
profile.AdditionalDataJson = request.AdditionalDataJson; profile.AdditionalDataJson = request.AdditionalDataJson;
profile.Status = TenantVerificationStatus.Pending; profile.Status = TenantVerificationStatus.Pending;
profile.SubmittedAt = DateTime.UtcNow; profile.SubmittedAt = DateTime.UtcNow;

View File

@@ -31,6 +31,8 @@ internal static class TenantMapping
BankAccountName = profile.BankAccountName, BankAccountName = profile.BankAccountName,
BankAccountNumber = profile.BankAccountNumber, BankAccountNumber = profile.BankAccountNumber,
BankName = profile.BankName, BankName = profile.BankName,
WeChatMerchantNo = profile.WeChatMerchantNo,
AlipayPid = profile.AlipayPid,
AdditionalDataJson = profile.AdditionalDataJson, AdditionalDataJson = profile.AdditionalDataJson,
SubmittedAt = profile.SubmittedAt, SubmittedAt = profile.SubmittedAt,
ReviewRemarks = profile.ReviewRemarks, ReviewRemarks = profile.ReviewRemarks,

View File

@@ -0,0 +1,128 @@
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Domain.Finance.Models;
/// <summary>
/// 到账查询汇总行。
/// </summary>
public sealed record FinanceSettlementListItemSnapshot
{
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public required DateTime ArrivedDate { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public required PaymentMethod PaymentMethod { get; init; }
/// <summary>
/// 交易笔数。
/// </summary>
public required int TransactionCount { get; init; }
/// <summary>
/// 到账金额。
/// </summary>
public required decimal ArrivedAmount { get; init; }
}
/// <summary>
/// 到账查询明细行。
/// </summary>
public sealed record FinanceSettlementDetailItemSnapshot
{
/// <summary>
/// 订单号。
/// </summary>
public required string OrderNo { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public required decimal Amount { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public required DateTime PaidAt { get; init; }
}
/// <summary>
/// 到账查询分页快照。
/// </summary>
public sealed record FinanceSettlementPageSnapshot
{
/// <summary>
/// 列表项。
/// </summary>
public required IReadOnlyList<FinanceSettlementListItemSnapshot> Items { get; init; }
/// <summary>
/// 总数。
/// </summary>
public required int TotalCount { get; init; }
}
/// <summary>
/// 到账概览统计快照。
/// </summary>
public sealed record FinanceSettlementStatsSnapshot
{
/// <summary>
/// 今日到账。
/// </summary>
public required decimal TodayArrivedAmount { get; init; }
/// <summary>
/// 昨日到账。
/// </summary>
public required decimal YesterdayArrivedAmount { get; init; }
/// <summary>
/// 本月到账。
/// </summary>
public required decimal CurrentMonthArrivedAmount { get; init; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public required int CurrentMonthTransactionCount { get; init; }
}
/// <summary>
/// 到账账户信息快照。
/// </summary>
public sealed record FinanceSettlementAccountSnapshot
{
/// <summary>
/// 银行名称。
/// </summary>
public required string BankName { get; init; }
/// <summary>
/// 开户名。
/// </summary>
public required string BankAccountName { get; init; }
/// <summary>
/// 脱敏银行账号。
/// </summary>
public required string BankAccountNoMasked { get; init; }
/// <summary>
/// 微信商户号(脱敏)。
/// </summary>
public required string WechatMerchantNoMasked { get; init; }
/// <summary>
/// 支付宝 PID脱敏
/// </summary>
public required string AlipayPidMasked { get; init; }
/// <summary>
/// 结算周期文案。
/// </summary>
public required string SettlementPeriodText { get; init; }
}

View File

@@ -63,4 +63,55 @@ public interface IFinanceTransactionRepository
PaymentMethod? paymentMethod, PaymentMethod? paymentMethod,
string? keyword, string? keyword,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账概览统计。
/// </summary>
Task<FinanceSettlementStatsSnapshot> GetSettlementStatsAsync(
long tenantId,
long storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账账户信息。
/// </summary>
Task<FinanceSettlementAccountSnapshot?> GetSettlementAccountAsync(
long tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账汇总分页。
/// </summary>
Task<FinanceSettlementPageSnapshot> SearchSettlementPageAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账明细。
/// </summary>
Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> GetSettlementDetailsAsync(
long tenantId,
long storeId,
DateTime arrivedDate,
PaymentMethod paymentMethod,
int take,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账导出数据。
/// </summary>
Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> ListSettlementForExportAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
CancellationToken cancellationToken = default);
} }

View File

@@ -23,6 +23,14 @@ public interface IMiniUserRepository
/// <returns>小程序用户,如果不存在则返回 null</returns> /// <returns>小程序用户,如果不存在则返回 null</returns>
Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default); Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 按用户标识集合批量查询小程序用户。
/// </summary>
Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
IReadOnlyCollection<long> ids,
long tenantId,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
/// </summary> /// </summary>

View File

@@ -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; }
}

View File

@@ -0,0 +1,95 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员积分商城兑换商品。
/// </summary>
public sealed class MemberPointMallProduct : MultiTenantEntityBase
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 展示名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 展示图片地址。
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// 兑换类型。
/// </summary>
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product;
/// <summary>
/// 关联商品 ID兑换商品时必填
/// </summary>
public long? ProductId { get; set; }
/// <summary>
/// 关联优惠券模板 ID兑换优惠券时必填
/// </summary>
public long? CouponTemplateId { get; set; }
/// <summary>
/// 实物名称(兑换实物时必填)。
/// </summary>
public string? PhysicalName { get; set; }
/// <summary>
/// 实物领取方式。
/// </summary>
public MemberPointMallPickupMethod? PickupMethod { get; set; }
/// <summary>
/// 商品描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 兑换方式(纯积分/积分+现金)。
/// </summary>
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
/// <summary>
/// 所需积分。
/// </summary>
public int RequiredPoints { get; set; }
/// <summary>
/// 现金部分(积分+现金时使用)。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 初始库存数量。
/// </summary>
public int StockTotal { get; set; }
/// <summary>
/// 剩余库存数量。
/// </summary>
public int StockAvailable { get; set; }
/// <summary>
/// 每人限兑次数null 表示不限)。
/// </summary>
public int? PerMemberLimit { get; set; }
/// <summary>
/// 到账通知渠道JSON 数组)。
/// </summary>
public string NotifyChannelsJson { get; set; } = "[]";
/// <summary>
/// 上下架状态。
/// </summary>
public MemberPointMallProductStatus Status { get; set; } = MemberPointMallProductStatus.Enabled;
}

View File

@@ -0,0 +1,100 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员积分商城兑换记录。
/// </summary>
public sealed class MemberPointMallRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 兑换记录单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 关联积分商品 ID。
/// </summary>
public long PointMallProductId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 会员名称快照。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号快照(脱敏)。
/// </summary>
public string MemberMobileMasked { get; set; } = string.Empty;
/// <summary>
/// 商品名称快照。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 兑换类型快照。
/// </summary>
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product;
/// <summary>
/// 兑换方式快照。
/// </summary>
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
/// <summary>
/// 消耗积分。
/// </summary>
public int UsedPoints { get; set; }
/// <summary>
/// 现金部分。
/// </summary>
public decimal CashAmount { get; set; }
/// <summary>
/// 记录状态。
/// </summary>
public MemberPointMallRecordStatus Status { get; set; } = MemberPointMallRecordStatus.Issued;
/// <summary>
/// 兑换时间UTC
/// </summary>
public DateTime RedeemedAt { get; set; }
/// <summary>
/// 发放时间UTC
/// </summary>
public DateTime? IssuedAt { get; set; }
/// <summary>
/// 核销时间UTC
/// </summary>
public DateTime? VerifiedAt { get; set; }
/// <summary>
/// 核销方式。
/// </summary>
public MemberPointMallVerifyMethod? VerifyMethod { get; set; }
/// <summary>
/// 核销备注。
/// </summary>
public string? VerifyRemark { get; set; }
/// <summary>
/// 核销人用户标识。
/// </summary>
public long? VerifiedBy { get; set; }
}

View File

@@ -0,0 +1,65 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员积分商城规则配置。
/// </summary>
public sealed class MemberPointMallRule : MultiTenantEntityBase
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 是否启用消费获取。
/// </summary>
public bool IsConsumeRewardEnabled { get; set; } = true;
/// <summary>
/// 每消费多少元触发一次积分计算。
/// </summary>
public int ConsumeAmountPerStep { get; set; } = 1;
/// <summary>
/// 每步获得积分。
/// </summary>
public int ConsumeRewardPointsPerStep { get; set; } = 1;
/// <summary>
/// 是否启用评价奖励。
/// </summary>
public bool IsReviewRewardEnabled { get; set; } = true;
/// <summary>
/// 评价奖励积分。
/// </summary>
public int ReviewRewardPoints { get; set; } = 10;
/// <summary>
/// 是否启用注册奖励。
/// </summary>
public bool IsRegisterRewardEnabled { get; set; } = true;
/// <summary>
/// 注册奖励积分。
/// </summary>
public int RegisterRewardPoints { get; set; } = 100;
/// <summary>
/// 是否启用签到奖励。
/// </summary>
public bool IsSigninRewardEnabled { get; set; }
/// <summary>
/// 签到奖励积分。
/// </summary>
public int SigninRewardPoints { get; set; } = 5;
/// <summary>
/// 积分有效期模式。
/// </summary>
public MemberPointMallExpiryMode ExpiryMode { get; set; } = MemberPointMallExpiryMode.YearlyClear;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,18 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 会员消息目标人群类型。
/// </summary>
public enum MemberMessageAudienceType
{
/// <summary>
/// 全部会员。
/// </summary>
All = 0,
/// <summary>
/// 按标签筛选。
/// </summary>
Tags = 1
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,18 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 消息发送时间类型。
/// </summary>
public enum MemberMessageScheduleType
{
/// <summary>
/// 立即发送。
/// </summary>
Immediate = 0,
/// <summary>
/// 定时发送。
/// </summary>
Scheduled = 1
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 兑换方式。
/// </summary>
public enum MemberPointMallExchangeType
{
/// <summary>
/// 纯积分。
/// </summary>
PointsOnly = 0,
/// <summary>
/// 积分 + 现金。
/// </summary>
PointsAndCash = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 积分有效期模式。
/// </summary>
public enum MemberPointMallExpiryMode
{
/// <summary>
/// 永久有效。
/// </summary>
Permanent = 0,
/// <summary>
/// 按年清零(每年 12 月 31 日)。
/// </summary>
YearlyClear = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 到账通知渠道。
/// </summary>
public enum MemberPointMallNotifyChannel
{
/// <summary>
/// 站内消息。
/// </summary>
InApp = 0,
/// <summary>
/// 短信通知。
/// </summary>
Sms = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 实物领取方式。
/// </summary>
public enum MemberPointMallPickupMethod
{
/// <summary>
/// 到店自提。
/// </summary>
StorePickup = 0,
/// <summary>
/// 快递配送。
/// </summary>
Delivery = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 积分商城商品状态。
/// </summary>
public enum MemberPointMallProductStatus
{
/// <summary>
/// 下架。
/// </summary>
Disabled = 0,
/// <summary>
/// 上架。
/// </summary>
Enabled = 1
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 兑换记录状态。
/// </summary>
public enum MemberPointMallRecordStatus
{
/// <summary>
/// 待领取。
/// </summary>
PendingPickup = 0,
/// <summary>
/// 已发放。
/// </summary>
Issued = 1,
/// <summary>
/// 已完成。
/// </summary>
Completed = 2,
/// <summary>
/// 已取消。
/// </summary>
Canceled = 3
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 积分兑换类型。
/// </summary>
public enum MemberPointMallRedeemType
{
/// <summary>
/// 兑换商品。
/// </summary>
Product = 0,
/// <summary>
/// 兑换优惠券。
/// </summary>
Coupon = 1,
/// <summary>
/// 兑换实物。
/// </summary>
Physical = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 核销方式。
/// </summary>
public enum MemberPointMallVerifyMethod
{
/// <summary>
/// 扫码核销。
/// </summary>
Scan = 0,
/// <summary>
/// 手动核销。
/// </summary>
Manual = 1
}

View File

@@ -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);

View File

@@ -83,6 +83,14 @@ public interface IMemberRepository
long memberProfileId, long memberProfileId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 按会员集合批量查询标签。
/// </summary>
Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
long tenantId,
IReadOnlyCollection<long> memberProfileIds,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 替换会员标签集合。 /// 替换会员标签集合。
/// </summary> /// </summary>

View File

@@ -0,0 +1,245 @@
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
namespace TakeoutSaaS.Domain.Membership.Repositories;
/// <summary>
/// 会员积分商城仓储契约。
/// </summary>
public interface IPointMallRepository
{
/// <summary>
/// 查询门店积分规则。
/// </summary>
Task<MemberPointMallRule?> GetRuleByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增积分规则。
/// </summary>
Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新积分规则。
/// </summary>
Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询兑换商品列表。
/// </summary>
Task<IReadOnlyList<MemberPointMallProduct>> SearchProductsAsync(
long tenantId,
long storeId,
MemberPointMallProductStatus? status,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询兑换商品(追踪)。
/// </summary>
Task<MemberPointMallProduct?> FindProductByIdAsync(
long tenantId,
long storeId,
long productId,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询兑换商品(只读)。
/// </summary>
Task<MemberPointMallProduct?> GetProductByIdAsync(
long tenantId,
long storeId,
long productId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增兑换商品。
/// </summary>
Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新兑换商品。
/// </summary>
Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
/// <summary>
/// 删除兑换商品。
/// </summary>
Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询商品是否已有兑换记录。
/// </summary>
Task<bool> HasRecordsByProductIdAsync(
long tenantId,
long storeId,
long pointMallProductId,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计会员在某商品上的有效兑换次数(排除已取消)。
/// </summary>
Task<int> CountMemberRedeemsByProductAsync(
long tenantId,
long storeId,
long pointMallProductId,
long memberId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询兑换记录分页。
/// </summary>
Task<(IReadOnlyList<MemberPointMallRecord> Items, int TotalCount)> SearchRecordsAsync(
long tenantId,
long storeId,
MemberPointMallRedeemType? redeemType,
MemberPointMallRecordStatus? status,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询兑换记录详情。
/// </summary>
Task<MemberPointMallRecord?> GetRecordByIdAsync(
long tenantId,
long storeId,
long recordId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询兑换记录(追踪)。
/// </summary>
Task<MemberPointMallRecord?> FindRecordByIdAsync(
long tenantId,
long storeId,
long recordId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询兑换记录导出数据。
/// </summary>
Task<IReadOnlyList<MemberPointMallRecord>> ListRecordsForExportAsync(
long tenantId,
long storeId,
MemberPointMallRedeemType? redeemType,
MemberPointMallRecordStatus? status,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增兑换记录。
/// </summary>
Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新兑换记录。
/// </summary>
Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 新增积分流水。
/// </summary>
Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询规则页统计。
/// </summary>
Task<MemberPointMallRuleStatsSnapshot> GetRuleStatsAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询记录页统计。
/// </summary>
Task<MemberPointMallRecordStatsSnapshot> GetRecordStatsAsync(
long tenantId,
long storeId,
DateTime nowUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询商品聚合统计快照。
/// </summary>
Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> pointMallProductIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 积分商城规则页统计快照。
/// </summary>
public sealed record MemberPointMallRuleStatsSnapshot
{
/// <summary>
/// 累计发放积分。
/// </summary>
public int TotalIssuedPoints { get; init; }
/// <summary>
/// 已兑换积分。
/// </summary>
public int RedeemedPoints { get; init; }
/// <summary>
/// 积分用户数。
/// </summary>
public int PointMembers { get; init; }
/// <summary>
/// 兑换率0-100
/// </summary>
public decimal RedeemRate { get; init; }
}
/// <summary>
/// 积分商城记录页统计快照。
/// </summary>
public sealed record MemberPointMallRecordStatsSnapshot
{
/// <summary>
/// 今日兑换数量。
/// </summary>
public int TodayRedeemCount { get; init; }
/// <summary>
/// 待领取实物数量。
/// </summary>
public int PendingPhysicalCount { get; init; }
/// <summary>
/// 本月消耗积分。
/// </summary>
public int CurrentMonthUsedPoints { get; init; }
}
/// <summary>
/// 积分商城商品聚合快照。
/// </summary>
public sealed record MemberPointMallProductAggregateSnapshot
{
/// <summary>
/// 商品标识。
/// </summary>
public required long PointMallProductId { get; init; }
/// <summary>
/// 已兑换数量。
/// </summary>
public int RedeemedCount { get; init; }
}

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Domain.Tenants.Enums; using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities; using TakeoutSaaS.Shared.Abstractions.Entities;
@@ -63,6 +64,18 @@ public sealed class TenantVerificationProfile : AuditableEntityBase
/// </summary> /// </summary>
public string? BankName { get; set; } public string? BankName { get; set; }
/// <summary>
/// 微信商户号。
/// </summary>
[MaxLength(64)]
public string? WeChatMerchantNo { get; set; }
/// <summary>
/// 支付宝 PID。
/// </summary>
[MaxLength(64)]
public string? AlipayPid { get; set; }
/// <summary> /// <summary>
/// 附加资料JSON /// 附加资料JSON
/// </summary> /// </summary>

View File

@@ -52,6 +52,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>(); services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>(); services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IMemberRepository, EfMemberRepository>(); services.AddScoped<IMemberRepository, EfMemberRepository>();
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>(); services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>(); services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>(); services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();

View File

@@ -402,6 +402,18 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>(); public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
/// <summary> /// <summary>
/// 积分商城规则。
/// </summary>
public DbSet<MemberPointMallRule> MemberPointMallRules => Set<MemberPointMallRule>();
/// <summary>
/// 积分商城兑换商品。
/// </summary>
public DbSet<MemberPointMallProduct> MemberPointMallProducts => Set<MemberPointMallProduct>();
/// <summary>
/// 积分商城兑换记录。
/// </summary>
public DbSet<MemberPointMallRecord> MemberPointMallRecords => Set<MemberPointMallRecord>();
/// <summary>
/// 会员储值方案。 /// 会员储值方案。
/// </summary> /// </summary>
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>(); public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
@@ -410,6 +422,18 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>(); public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>();
/// <summary> /// <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> /// </summary>
public DbSet<ChatSession> ChatSessions => Set<ChatSession>(); public DbSet<ChatSession> ChatSessions => Set<ChatSession>();
@@ -576,8 +600,14 @@ public sealed class TakeoutAppDbContext(
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>()); ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>()); ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>()); ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
ConfigureMemberPointMallRule(modelBuilder.Entity<MemberPointMallRule>());
ConfigureMemberPointMallProduct(modelBuilder.Entity<MemberPointMallProduct>());
ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>());
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>()); ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>()); ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
ConfigureMemberReachMessage(modelBuilder.Entity<MemberReachMessage>());
ConfigureMemberMessageTemplate(modelBuilder.Entity<MemberMessageTemplate>());
ConfigureMemberReachRecipient(modelBuilder.Entity<MemberReachRecipient>());
ConfigureChatSession(modelBuilder.Entity<ChatSession>()); ConfigureChatSession(modelBuilder.Entity<ChatSession>());
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>()); ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>()); ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>());
@@ -1856,6 +1886,80 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt }); builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
} }
private static void ConfigureMemberPointMallRule(EntityTypeBuilder<MemberPointMallRule> builder)
{
builder.ToTable("member_point_mall_rules");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.IsConsumeRewardEnabled).IsRequired();
builder.Property(x => x.ConsumeAmountPerStep).IsRequired();
builder.Property(x => x.ConsumeRewardPointsPerStep).IsRequired();
builder.Property(x => x.IsReviewRewardEnabled).IsRequired();
builder.Property(x => x.ReviewRewardPoints).IsRequired();
builder.Property(x => x.IsRegisterRewardEnabled).IsRequired();
builder.Property(x => x.RegisterRewardPoints).IsRequired();
builder.Property(x => x.IsSigninRewardEnabled).IsRequired();
builder.Property(x => x.SigninRewardPoints).IsRequired();
builder.Property(x => x.ExpiryMode).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
}
private static void ConfigureMemberPointMallProduct(EntityTypeBuilder<MemberPointMallProduct> builder)
{
builder.ToTable("member_point_mall_products");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.ImageUrl).HasMaxLength(512);
builder.Property(x => x.RedeemType).HasConversion<int>();
builder.Property(x => x.ProductId);
builder.Property(x => x.CouponTemplateId);
builder.Property(x => x.PhysicalName).HasMaxLength(64);
builder.Property(x => x.PickupMethod).HasConversion<int?>();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.ExchangeType).HasConversion<int>();
builder.Property(x => x.RequiredPoints).IsRequired();
builder.Property(x => x.CashAmount).HasPrecision(18, 2);
builder.Property(x => x.StockTotal).IsRequired();
builder.Property(x => x.StockAvailable).IsRequired();
builder.Property(x => x.PerMemberLimit);
builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductId });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.CouponTemplateId });
}
private static void ConfigureMemberPointMallRecord(EntityTypeBuilder<MemberPointMallRecord> builder)
{
builder.ToTable("member_point_mall_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.PointMallProductId).IsRequired();
builder.Property(x => x.MemberId).IsRequired();
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
builder.Property(x => x.MemberMobileMasked).HasMaxLength(32).IsRequired();
builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
builder.Property(x => x.RedeemType).HasConversion<int>();
builder.Property(x => x.ExchangeType).HasConversion<int>();
builder.Property(x => x.UsedPoints).IsRequired();
builder.Property(x => x.CashAmount).HasPrecision(18, 2);
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.RedeemedAt).IsRequired();
builder.Property(x => x.IssuedAt);
builder.Property(x => x.VerifiedAt);
builder.Property(x => x.VerifyMethod).HasConversion<int?>();
builder.Property(x => x.VerifyRemark).HasMaxLength(256);
builder.Property(x => x.VerifiedBy);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PointMallProductId, x.RedeemedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.MemberId, x.RedeemedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.RedeemedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RedeemedAt });
}
private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder<MemberStoredCardPlan> builder) private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder<MemberStoredCardPlan> builder)
{ {
builder.ToTable("member_stored_card_plans"); builder.ToTable("member_stored_card_plans");
@@ -1892,6 +1996,62 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt }); 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) private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder)
{ {
builder.ToTable("chat_sessions"); builder.ToTable("chat_sessions");
@@ -2102,3 +2262,4 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt }); builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
} }
} }

Some files were not shown because too many files have changed in this diff Show More