feat(member): implement points mall backend module

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

View File

@@ -0,0 +1,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,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

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

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

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

View File

@@ -0,0 +1,479 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 会员积分商城 EF Core 仓储实现。
/// </summary>
public sealed class EfPointMallRepository(TakeoutAppDbContext context) : IPointMallRepository
{
/// <inheritdoc />
public Task<MemberPointMallRule?> GetRuleByStoreAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallRules
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
{
return context.MemberPointMallRules.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
{
context.MemberPointMallRules.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberPointMallProduct>> SearchProductsAsync(
long tenantId,
long storeId,
MemberPointMallProductStatus? status,
string? keyword,
CancellationToken cancellationToken = default)
{
var query = context.MemberPointMallProducts
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.Name, like) ||
(item.PhysicalName != null && EF.Functions.ILike(item.PhysicalName, like)));
}
return await query
.OrderByDescending(item => item.Status)
.ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt)
.ThenByDescending(item => item.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberPointMallProduct?> FindProductByIdAsync(
long tenantId,
long storeId,
long productId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallProducts
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == productId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberPointMallProduct?> GetProductByIdAsync(
long tenantId,
long storeId,
long productId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallProducts
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == productId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
{
return context.MemberPointMallProducts.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
{
context.MemberPointMallProducts.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
{
context.MemberPointMallProducts.Remove(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> HasRecordsByProductIdAsync(
long tenantId,
long storeId,
long pointMallProductId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallRecords
.AsNoTracking()
.AnyAsync(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.PointMallProductId == pointMallProductId,
cancellationToken);
}
/// <inheritdoc />
public Task<int> CountMemberRedeemsByProductAsync(
long tenantId,
long storeId,
long pointMallProductId,
long memberId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.PointMallProductId == pointMallProductId &&
item.MemberId == memberId &&
item.Status != MemberPointMallRecordStatus.Canceled)
.CountAsync(cancellationToken);
}
/// <inheritdoc />
public async 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)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildRecordQuery(
tenantId,
storeId,
redeemType,
status,
startUtc,
endUtc,
keyword);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.RedeemedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public Task<MemberPointMallRecord?> GetRecordByIdAsync(
long tenantId,
long storeId,
long recordId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == recordId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberPointMallRecord?> FindRecordByIdAsync(
long tenantId,
long storeId,
long recordId,
CancellationToken cancellationToken = default)
{
return context.MemberPointMallRecords
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == recordId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberPointMallRecord>> ListRecordsForExportAsync(
long tenantId,
long storeId,
MemberPointMallRedeemType? redeemType,
MemberPointMallRecordStatus? status,
DateTime? startUtc,
DateTime? endUtc,
string? keyword,
CancellationToken cancellationToken = default)
{
return await BuildRecordQuery(
tenantId,
storeId,
redeemType,
status,
startUtc,
endUtc,
keyword)
.OrderByDescending(item => item.RedeemedAt)
.ThenByDescending(item => item.Id)
.Take(20_000)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
{
return context.MemberPointMallRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
{
context.MemberPointMallRecords.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default)
{
return context.MemberPointLedgers.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<MemberPointMallRuleStatsSnapshot> GetRuleStatsAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
var redeemedPoints = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.Select(item => (int?)item.UsedPoints)
.SumAsync(cancellationToken) ?? 0;
var memberIds = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.Select(item => item.MemberId)
.Distinct()
.ToListAsync(cancellationToken);
var pointMembers = memberIds.Count;
if (pointMembers == 0)
{
return new MemberPointMallRuleStatsSnapshot
{
TotalIssuedPoints = 0,
RedeemedPoints = 0,
PointMembers = 0,
RedeemRate = 0m
};
}
var currentPoints = await context.MemberProfiles
.AsNoTracking()
.Where(item => item.TenantId == tenantId && memberIds.Contains(item.Id))
.Select(item => (int?)item.PointsBalance)
.SumAsync(cancellationToken) ?? 0;
var totalIssuedPoints = Math.Max(redeemedPoints + Math.Max(0, currentPoints), 0);
var redeemRate = totalIssuedPoints <= 0
? 0m
: decimal.Round((decimal)redeemedPoints * 100m / totalIssuedPoints, 1, MidpointRounding.AwayFromZero);
return new MemberPointMallRuleStatsSnapshot
{
TotalIssuedPoints = totalIssuedPoints,
RedeemedPoints = redeemedPoints,
PointMembers = pointMembers,
RedeemRate = redeemRate
};
}
/// <inheritdoc />
public async Task<MemberPointMallRecordStatsSnapshot> GetRecordStatsAsync(
long tenantId,
long storeId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var normalizedNow = NormalizeUtc(nowUtc);
var dayStart = new DateTime(
normalizedNow.Year,
normalizedNow.Month,
normalizedNow.Day,
0,
0,
0,
DateTimeKind.Utc);
var monthStart = new DateTime(
normalizedNow.Year,
normalizedNow.Month,
1,
0,
0,
0,
DateTimeKind.Utc);
var todayRedeemCount = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RedeemedAt >= dayStart &&
item.RedeemedAt <= normalizedNow)
.CountAsync(cancellationToken);
var pendingPhysicalCount = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RedeemType == MemberPointMallRedeemType.Physical &&
item.Status == MemberPointMallRecordStatus.PendingPickup)
.CountAsync(cancellationToken);
var currentMonthUsedPoints = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.RedeemedAt >= monthStart &&
item.RedeemedAt <= normalizedNow)
.Select(item => (int?)item.UsedPoints)
.SumAsync(cancellationToken) ?? 0;
return new MemberPointMallRecordStatsSnapshot
{
TodayRedeemCount = todayRedeemCount,
PendingPhysicalCount = pendingPhysicalCount,
CurrentMonthUsedPoints = currentMonthUsedPoints
};
}
/// <inheritdoc />
public async Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> pointMallProductIds,
CancellationToken cancellationToken = default)
{
if (pointMallProductIds.Count == 0)
{
return [];
}
var aggregates = await context.MemberPointMallRecords
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
pointMallProductIds.Contains(item.PointMallProductId))
.GroupBy(item => item.PointMallProductId)
.Select(group => new MemberPointMallProductAggregateSnapshot
{
PointMallProductId = group.Key,
RedeemedCount = group.Count()
})
.ToListAsync(cancellationToken);
return aggregates.ToDictionary(item => item.PointMallProductId, item => item);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<MemberPointMallRecord> BuildRecordQuery(
long tenantId,
long storeId,
MemberPointMallRedeemType? redeemType,
MemberPointMallRecordStatus? status,
DateTime? startUtc,
DateTime? endUtc,
string? keyword)
{
var query = context.MemberPointMallRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (redeemType.HasValue)
{
query = query.Where(item => item.RedeemType == redeemType.Value);
}
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
if (startUtc.HasValue)
{
var normalizedStart = NormalizeUtc(startUtc.Value);
query = query.Where(item => item.RedeemedAt >= normalizedStart);
}
if (endUtc.HasValue)
{
var normalizedEnd = NormalizeUtc(endUtc.Value);
query = query.Where(item => item.RedeemedAt <= normalizedEnd);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.RecordNo, like) ||
EF.Functions.ILike(item.MemberName, like) ||
EF.Functions.ILike(item.MemberMobileMasked, like) ||
EF.Functions.ILike(item.ProductName, like));
}
return query;
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
}

View File

@@ -0,0 +1,187 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberPointsMallModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "member_point_mall_products",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "展示名称。"),
ImageUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "展示图片地址。"),
RedeemType = table.Column<int>(type: "integer", nullable: false, comment: "兑换类型。"),
ProductId = table.Column<long>(type: "bigint", nullable: true, comment: "关联商品 ID兑换商品时必填。"),
CouponTemplateId = table.Column<long>(type: "bigint", nullable: true, comment: "关联优惠券模板 ID兑换优惠券时必填。"),
PhysicalName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "实物名称(兑换实物时必填)。"),
PickupMethod = table.Column<int>(type: "integer", nullable: true, comment: "实物领取方式。"),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "商品描述。"),
ExchangeType = table.Column<int>(type: "integer", nullable: false, comment: "兑换方式(纯积分/积分+现金)。"),
RequiredPoints = table.Column<int>(type: "integer", nullable: false, comment: "所需积分。"),
CashAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分(积分+现金时使用)。"),
StockTotal = table.Column<int>(type: "integer", nullable: false, comment: "初始库存数量。"),
StockAvailable = table.Column<int>(type: "integer", nullable: false, comment: "剩余库存数量。"),
PerMemberLimit = table.Column<int>(type: "integer", nullable: true, comment: "每人限兑次数null 表示不限)。"),
NotifyChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "到账通知渠道JSON 数组)。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "上下架状态。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_point_mall_products", x => x.Id);
},
comment: "会员积分商城兑换商品。");
migrationBuilder.CreateTable(
name: "member_point_mall_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "兑换记录单号。"),
PointMallProductId = table.Column<long>(type: "bigint", nullable: false, comment: "关联积分商品 ID。"),
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称快照。"),
MemberMobileMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号快照(脱敏)。"),
ProductName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称快照。"),
RedeemType = table.Column<int>(type: "integer", nullable: false, comment: "兑换类型快照。"),
ExchangeType = table.Column<int>(type: "integer", nullable: false, comment: "兑换方式快照。"),
UsedPoints = table.Column<int>(type: "integer", nullable: false, comment: "消耗积分。"),
CashAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "记录状态。"),
RedeemedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "兑换时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发放时间UTC。"),
VerifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "核销时间UTC。"),
VerifyMethod = table.Column<int>(type: "integer", nullable: true, comment: "核销方式。"),
VerifyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "核销备注。"),
VerifiedBy = table.Column<long>(type: "bigint", nullable: true, comment: "核销人用户标识。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_point_mall_records", x => x.Id);
},
comment: "会员积分商城兑换记录。");
migrationBuilder.CreateTable(
name: "member_point_mall_rules",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
IsConsumeRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用消费获取。"),
ConsumeAmountPerStep = table.Column<int>(type: "integer", nullable: false, comment: "每消费多少元触发一次积分计算。"),
ConsumeRewardPointsPerStep = table.Column<int>(type: "integer", nullable: false, comment: "每步获得积分。"),
IsReviewRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用评价奖励。"),
ReviewRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "评价奖励积分。"),
IsRegisterRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用注册奖励。"),
RegisterRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "注册奖励积分。"),
IsSigninRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用签到奖励。"),
SigninRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "签到奖励积分。"),
ExpiryMode = table.Column<int>(type: "integer", nullable: false, comment: "积分有效期模式。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_point_mall_rules", x => x.Id);
},
comment: "会员积分商城规则配置。");
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_products_TenantId_StoreId_CouponTemplateId",
table: "member_point_mall_products",
columns: new[] { "TenantId", "StoreId", "CouponTemplateId" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_products_TenantId_StoreId_Name",
table: "member_point_mall_products",
columns: new[] { "TenantId", "StoreId", "Name" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_products_TenantId_StoreId_ProductId",
table: "member_point_mall_products",
columns: new[] { "TenantId", "StoreId", "ProductId" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_products_TenantId_StoreId_Status",
table: "member_point_mall_products",
columns: new[] { "TenantId", "StoreId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_records_TenantId_StoreId_MemberId_RedeemedAt",
table: "member_point_mall_records",
columns: new[] { "TenantId", "StoreId", "MemberId", "RedeemedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_records_TenantId_StoreId_PointMallProductId_RedeemedAt",
table: "member_point_mall_records",
columns: new[] { "TenantId", "StoreId", "PointMallProductId", "RedeemedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_records_TenantId_StoreId_RecordNo",
table: "member_point_mall_records",
columns: new[] { "TenantId", "StoreId", "RecordNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_records_TenantId_StoreId_RedeemedAt",
table: "member_point_mall_records",
columns: new[] { "TenantId", "StoreId", "RedeemedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_records_TenantId_StoreId_Status_RedeemedAt",
table: "member_point_mall_records",
columns: new[] { "TenantId", "StoreId", "Status", "RedeemedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_point_mall_rules_TenantId_StoreId",
table: "member_point_mall_rules",
columns: new[] { "TenantId", "StoreId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "member_point_mall_products");
migrationBuilder.DropTable(
name: "member_point_mall_records");
migrationBuilder.DropTable(
name: "member_point_mall_rules");
}
}
}