forked from gongxuegit/tiku-backend.net
feat: add tenant points and coupon operations
This commit is contained in:
@@ -17,6 +17,10 @@ public sealed class TenantCommerceQueryDto
|
|||||||
|
|
||||||
[Range(1, 200)]
|
[Range(1, 200)]
|
||||||
public int? Limit { get; set; }
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
|
||||||
|
public Guid? RegionId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpsertPaymentAccountDto
|
public sealed class UpsertPaymentAccountDto
|
||||||
@@ -134,3 +138,162 @@ public sealed class RedeemActivationCodeDto
|
|||||||
return new RedeemActivationCodeCommand(Code, UserId, RegionId);
|
return new RedeemActivationCodeCommand(Code, UserId, RegionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertPointTaskDto
|
||||||
|
{
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string TaskKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(200)]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public PointActivityTaskType TaskType { get; set; } = PointActivityTaskType.Manual;
|
||||||
|
public PointActivityTaskStatus Status { get; set; } = PointActivityTaskStatus.Active;
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int Points { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 1000)]
|
||||||
|
public int MaxClaimsPerUser { get; set; } = 1;
|
||||||
|
|
||||||
|
public DateTimeOffset? StartsAt { get; set; }
|
||||||
|
public DateTimeOffset? EndsAt { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public JsonElement Rules { get; set; } = JsonDefaults.Object();
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
|
||||||
|
public UpsertPointTaskCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpsertPointTaskCommand(
|
||||||
|
Id,
|
||||||
|
TaskKey,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
TaskType,
|
||||||
|
Status,
|
||||||
|
Points,
|
||||||
|
MaxClaimsPerUser,
|
||||||
|
StartsAt,
|
||||||
|
EndsAt,
|
||||||
|
SortOrder,
|
||||||
|
Rules,
|
||||||
|
Metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertPointExchangeItemDto
|
||||||
|
{
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
public Guid? RegionId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(200)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public PointExchangeItemType ItemType { get; set; } = PointExchangeItemType.Entitlement;
|
||||||
|
public PointExchangeItemStatus Status { get; set; } = PointExchangeItemStatus.Active;
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int PointsCost { get; set; }
|
||||||
|
|
||||||
|
[Range(0, int.MaxValue)]
|
||||||
|
public int? Stock { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 3650)]
|
||||||
|
public int? Days { get; set; }
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public DateTimeOffset? StartsAt { get; set; }
|
||||||
|
public DateTimeOffset? EndsAt { get; set; }
|
||||||
|
public JsonElement FulfillmentPayload { get; set; } = JsonDefaults.Object();
|
||||||
|
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||||
|
|
||||||
|
public UpsertPointExchangeItemCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpsertPointExchangeItemCommand(
|
||||||
|
Id,
|
||||||
|
RegionId,
|
||||||
|
ItemKey,
|
||||||
|
Name,
|
||||||
|
Description,
|
||||||
|
ItemType,
|
||||||
|
Status,
|
||||||
|
PointsCost,
|
||||||
|
Stock,
|
||||||
|
Days,
|
||||||
|
SortOrder,
|
||||||
|
StartsAt,
|
||||||
|
EndsAt,
|
||||||
|
FulfillmentPayload,
|
||||||
|
Metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdatePointExchangeOrderStatusDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid OrderId { get; set; }
|
||||||
|
|
||||||
|
public PointExchangeOrderStatus Status { get; set; }
|
||||||
|
|
||||||
|
public UpdatePointExchangeOrderStatusCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpdatePointExchangeOrderStatusCommand(OrderId, Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertTenantCouponDto
|
||||||
|
{
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid? PlanId { get; set; }
|
||||||
|
public DiscountType DiscountType { get; set; } = DiscountType.Fixed;
|
||||||
|
|
||||||
|
[Range(typeof(decimal), "0.01", "999999")]
|
||||||
|
public decimal DiscountValue { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? ValidFrom { get; set; }
|
||||||
|
public DateTimeOffset? ValidTo { get; set; }
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int? MaxUses { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? Source { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
|
||||||
|
public UpsertCouponCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpsertCouponCommand(
|
||||||
|
Id,
|
||||||
|
Code,
|
||||||
|
PlanId,
|
||||||
|
DiscountType,
|
||||||
|
DiscountValue,
|
||||||
|
ValidFrom,
|
||||||
|
ValidTo,
|
||||||
|
MaxUses,
|
||||||
|
Source,
|
||||||
|
Remark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,149 @@ public sealed class TenantCommerceController(
|
|||||||
cancellationToken));
|
cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("point-activity-tasks")]
|
||||||
|
[EndpointSummary("查询积分活动任务")]
|
||||||
|
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetPointTasksAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToPointQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("point-activity-tasks")]
|
||||||
|
[EndpointSummary("新增或更新积分活动任务")]
|
||||||
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<object>> UpsertPointTask(
|
||||||
|
UpsertPointTaskDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.UpsertPointTaskAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("point-activity-claims")]
|
||||||
|
[EndpointSummary("查询积分任务领取记录")]
|
||||||
|
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetPointClaimsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToPointQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("point-exchange-items")]
|
||||||
|
[EndpointSummary("查询积分兑换项")]
|
||||||
|
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetPointExchangeItemsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToPointQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("point-exchange-items")]
|
||||||
|
[EndpointSummary("新增或更新积分兑换项")]
|
||||||
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<object>> UpsertPointExchangeItem(
|
||||||
|
UpsertPointExchangeItemDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.UpsertPointExchangeItemAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("point-exchange-orders")]
|
||||||
|
[EndpointSummary("查询积分兑换订单")]
|
||||||
|
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetPointExchangeOrdersAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToPointQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("point-exchange-orders/status")]
|
||||||
|
[EndpointSummary("更新积分兑换订单状态")]
|
||||||
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
|
||||||
|
UpdatePointExchangeOrderStatusDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.UpdatePointExchangeOrderStatusAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("coupons")]
|
||||||
|
[EndpointSummary("查询租户优惠券")]
|
||||||
|
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantCouponList>> Coupons(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetCouponsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("coupons")]
|
||||||
|
[EndpointSummary("新增或更新租户优惠券")]
|
||||||
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<object>> UpsertCoupon(
|
||||||
|
UpsertTenantCouponDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.UpsertCouponAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("coupons/redemptions")]
|
||||||
|
[EndpointSummary("查询优惠券领取和核销记录")]
|
||||||
|
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetCouponRedemptionsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("coupons/report")]
|
||||||
|
[EndpointSummary("查询优惠券基础报表")]
|
||||||
|
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TenantCouponReport>> CouponReport(
|
||||||
|
[FromQuery] TenantCommerceQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await commerceAdminService.GetCouponReportAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
ToQuery(query),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
private CommerceAdminActor ResolveActor()
|
private CommerceAdminActor ResolveActor()
|
||||||
{
|
{
|
||||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
@@ -133,4 +276,9 @@ public sealed class TenantCommerceController(
|
|||||||
{
|
{
|
||||||
return new CommerceAdminQuery(query.Provider, query.Status, query.Limit);
|
return new CommerceAdminQuery(query.Provider, query.Status, query.Limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TenantPointQuery ToPointQuery(TenantCommerceQueryDto query)
|
||||||
|
{
|
||||||
|
return new TenantPointQuery(query.Status, query.Limit, query.UserId, query.RegionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public sealed record CommerceAdminActor(Guid TenantId, Guid UserId);
|
|||||||
|
|
||||||
public sealed record CommerceAdminQuery(string? Provider = null, string? Status = null, int? Limit = null);
|
public sealed record CommerceAdminQuery(string? Provider = null, string? Status = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record TenantPointQuery(string? Status = null, int? Limit = null, Guid? UserId = null, Guid? RegionId = null);
|
||||||
|
|
||||||
public sealed record UpsertPaymentAccountCommand(
|
public sealed record UpsertPaymentAccountCommand(
|
||||||
string Provider,
|
string Provider,
|
||||||
TenantPaymentMode Mode,
|
TenantPaymentMode Mode,
|
||||||
@@ -91,6 +93,61 @@ public sealed record ActivationCodeList(IReadOnlyCollection<ActivationCodeItem>
|
|||||||
|
|
||||||
public sealed record RedeemActivationCodeCommand(string Code, Guid UserId, Guid? RegionId);
|
public sealed record RedeemActivationCodeCommand(string Code, Guid UserId, Guid? RegionId);
|
||||||
|
|
||||||
|
public sealed record UpsertPointTaskCommand(
|
||||||
|
Guid? Id,
|
||||||
|
string TaskKey,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
PointActivityTaskType TaskType,
|
||||||
|
PointActivityTaskStatus Status,
|
||||||
|
int Points,
|
||||||
|
int MaxClaimsPerUser,
|
||||||
|
DateTimeOffset? StartsAt,
|
||||||
|
DateTimeOffset? EndsAt,
|
||||||
|
int SortOrder,
|
||||||
|
JsonElement Rules,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record UpsertPointExchangeItemCommand(
|
||||||
|
Guid? Id,
|
||||||
|
Guid? RegionId,
|
||||||
|
string ItemKey,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
PointExchangeItemType ItemType,
|
||||||
|
PointExchangeItemStatus Status,
|
||||||
|
int PointsCost,
|
||||||
|
int? Stock,
|
||||||
|
int? Days,
|
||||||
|
int SortOrder,
|
||||||
|
DateTimeOffset? StartsAt,
|
||||||
|
DateTimeOffset? EndsAt,
|
||||||
|
JsonElement FulfillmentPayload,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record UpdatePointExchangeOrderStatusCommand(Guid OrderId, PointExchangeOrderStatus Status);
|
||||||
|
|
||||||
|
public sealed record TenantPointTaskList(IReadOnlyCollection<PointActivityTask> Items);
|
||||||
|
public sealed record TenantPointClaimList(IReadOnlyCollection<PointActivityClaim> Items);
|
||||||
|
public sealed record TenantPointExchangeItemList(IReadOnlyCollection<PointExchangeItem> Items);
|
||||||
|
public sealed record TenantPointExchangeOrderList(IReadOnlyCollection<PointExchangeOrder> Items);
|
||||||
|
|
||||||
|
public sealed record UpsertCouponCommand(
|
||||||
|
Guid? Id,
|
||||||
|
string Code,
|
||||||
|
Guid? PlanId,
|
||||||
|
DiscountType DiscountType,
|
||||||
|
decimal DiscountValue,
|
||||||
|
DateTimeOffset? ValidFrom,
|
||||||
|
DateTimeOffset? ValidTo,
|
||||||
|
int? MaxUses,
|
||||||
|
string? Source,
|
||||||
|
string? Remark);
|
||||||
|
|
||||||
|
public sealed record TenantCouponList(IReadOnlyCollection<Coupon> Items);
|
||||||
|
public sealed record TenantCouponRedemptionList(IReadOnlyCollection<CouponRedemption> Items);
|
||||||
|
public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int UsedCount, int DiscountAppliedCents);
|
||||||
|
|
||||||
public interface ICommerceAdminService
|
public interface ICommerceAdminService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
||||||
@@ -132,4 +189,59 @@ public interface ICommerceAdminService
|
|||||||
CommerceAdminActor actor,
|
CommerceAdminActor actor,
|
||||||
RedeemActivationCodeCommand command,
|
RedeemActivationCodeCommand command,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantPointTaskList> GetPointTasksAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointActivityTask> UpsertPointTaskAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertPointTaskCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantPointClaimList> GetPointClaimsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointExchangeItem> UpsertPointExchangeItemAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertPointExchangeItemCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpdatePointExchangeOrderStatusCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantCouponList> GetCouponsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Coupon> UpsertCouponAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertCouponCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantCouponRedemptionList> GetCouponRedemptionsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TenantCouponReport> GetCouponReportAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,291 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
|||||||
return ToActivationCodeItem(code);
|
return ToActivationCodeItem(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TenantPointTaskList> GetPointTasksAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var tasks = dbContext.PointActivityTasks.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await tasks
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantPointTaskList(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointActivityTask> UpsertPointTaskAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertPointTaskCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
|
||||||
|
{
|
||||||
|
throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = command.Id.HasValue
|
||||||
|
? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||||
|
cancellationToken)
|
||||||
|
: await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(),
|
||||||
|
cancellationToken);
|
||||||
|
if (task is null)
|
||||||
|
{
|
||||||
|
task = new PointActivityTask { TenantId = actor.TenantId };
|
||||||
|
dbContext.PointActivityTasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.TaskKey = command.TaskKey.Trim();
|
||||||
|
task.Title = command.Title.Trim();
|
||||||
|
task.Description = command.Description?.Trim();
|
||||||
|
task.TaskType = command.TaskType;
|
||||||
|
task.Status = command.Status;
|
||||||
|
task.Points = command.Points;
|
||||||
|
task.MaxClaimsPerUser = command.MaxClaimsPerUser;
|
||||||
|
task.StartsAt = command.StartsAt;
|
||||||
|
task.EndsAt = command.EndsAt;
|
||||||
|
task.SortOrder = command.SortOrder;
|
||||||
|
task.Rules = JsonObjectOrDefault(command.Rules);
|
||||||
|
task.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantPointClaimList> GetPointClaimsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var claims = dbContext.PointActivityClaims.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (query.UserId.HasValue)
|
||||||
|
{
|
||||||
|
claims = claims.Where(item => item.UserId == query.UserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await claims
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantPointClaimList(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var items = dbContext.PointExchangeItems.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await items
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantPointExchangeItemList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointExchangeItem> UpsertPointExchangeItemAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertPointExchangeItemCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
if (command.PointsCost <= 0)
|
||||||
|
{
|
||||||
|
throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = command.Id.HasValue
|
||||||
|
? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||||
|
entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value,
|
||||||
|
cancellationToken)
|
||||||
|
: await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||||
|
entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(),
|
||||||
|
cancellationToken);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
item = new PointExchangeItem { TenantId = actor.TenantId };
|
||||||
|
dbContext.PointExchangeItems.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.RegionId = command.RegionId;
|
||||||
|
item.ItemKey = command.ItemKey.Trim();
|
||||||
|
item.Name = command.Name.Trim();
|
||||||
|
item.Description = command.Description?.Trim();
|
||||||
|
item.ItemType = command.ItemType;
|
||||||
|
item.Status = command.Status;
|
||||||
|
item.PointsCost = command.PointsCost;
|
||||||
|
item.Stock = command.Stock;
|
||||||
|
item.Days = command.Days;
|
||||||
|
item.SortOrder = command.SortOrder;
|
||||||
|
item.StartsAt = command.StartsAt;
|
||||||
|
item.EndsAt = command.EndsAt;
|
||||||
|
item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload);
|
||||||
|
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
TenantPointQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var orders = dbContext.PointExchangeOrders.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (query.UserId.HasValue)
|
||||||
|
{
|
||||||
|
orders = orders.Where(item => item.UserId == query.UserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await orders
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantPointExchangeOrderList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpdatePointExchangeOrderStatusCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var order = await dbContext.PointExchangeOrders
|
||||||
|
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken)
|
||||||
|
?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found");
|
||||||
|
order.Status = command.Status;
|
||||||
|
if (command.Status == PointExchangeOrderStatus.Completed)
|
||||||
|
{
|
||||||
|
order.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||||
|
order.CancelledAt = null;
|
||||||
|
}
|
||||||
|
else if (command.Status == PointExchangeOrderStatus.Cancelled)
|
||||||
|
{
|
||||||
|
order.CancelledAt ??= DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantCouponList> GetCouponsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var coupons = dbContext.Coupons.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
var items = await coupons
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantCouponList(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Coupon> UpsertCouponAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
UpsertCouponCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var coupon = command.Id.HasValue
|
||||||
|
? await dbContext.Coupons.SingleOrDefaultAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||||
|
cancellationToken)
|
||||||
|
: await dbContext.Coupons.SingleOrDefaultAsync(
|
||||||
|
item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(),
|
||||||
|
cancellationToken);
|
||||||
|
if (coupon is null)
|
||||||
|
{
|
||||||
|
coupon = new Coupon { TenantId = actor.TenantId };
|
||||||
|
dbContext.Coupons.Add(coupon);
|
||||||
|
}
|
||||||
|
|
||||||
|
coupon.Code = command.Code.Trim();
|
||||||
|
coupon.PlanId = command.PlanId;
|
||||||
|
coupon.DiscountType = command.DiscountType;
|
||||||
|
coupon.DiscountValue = command.DiscountValue;
|
||||||
|
coupon.ValidFrom = command.ValidFrom;
|
||||||
|
coupon.ValidTo = command.ValidTo;
|
||||||
|
coupon.MaxUses = command.MaxUses;
|
||||||
|
coupon.Source = command.Source?.Trim();
|
||||||
|
coupon.Remark = command.Remark;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantCouponRedemptionList> GetCouponRedemptionsAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
{
|
||||||
|
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await redemptions
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return new TenantCouponRedemptionList(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TenantCouponReport> GetCouponReportAsync(
|
||||||
|
CommerceAdminActor actor,
|
||||||
|
CommerceAdminQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||||
|
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId);
|
||||||
|
var claimedCount = await redemptions.CountAsync(cancellationToken);
|
||||||
|
var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
|
||||||
|
var discountApplied = await redemptions
|
||||||
|
.Where(item => item.Status == CouponRedemptionStatus.Used)
|
||||||
|
.SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
|
||||||
|
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||||
@@ -321,6 +606,26 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
|||||||
? parsed
|
? parsed
|
||||||
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
||||||
|
|
||||||
|
private static PointActivityTaskStatus ParsePointTaskStatus(string? status) =>
|
||||||
|
Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
|
||||||
|
|
||||||
|
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) =>
|
||||||
|
Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status");
|
||||||
|
|
||||||
|
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) =>
|
||||||
|
Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status");
|
||||||
|
|
||||||
|
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) =>
|
||||||
|
Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
||||||
|
|
||||||
private static string NormalizeEnum(string? value) =>
|
private static string NormalizeEnum(string? value) =>
|
||||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,58 @@ public sealed class TenantCommerceEndpointTests
|
|||||||
item.SourceType == "activation_code");
|
item.SourceType == "activation_code");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_manage_points_and_coupons()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||||
|
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
var taskResponse = await client.PutAsJsonAsync(
|
||||||
|
"/api/tenant-commerce/point-activity-tasks",
|
||||||
|
new UpsertPointTaskDto
|
||||||
|
{
|
||||||
|
TaskKey = "admin_daily",
|
||||||
|
Title = "后台每日任务",
|
||||||
|
TaskType = PointActivityTaskType.DailyLogin,
|
||||||
|
Points = 10,
|
||||||
|
MaxClaimsPerUser = 1
|
||||||
|
});
|
||||||
|
var tasksResponse = await client.GetAsync("/api/tenant-commerce/point-activity-tasks");
|
||||||
|
var exchangeItemResponse = await client.PutAsJsonAsync(
|
||||||
|
"/api/tenant-commerce/point-exchange-items",
|
||||||
|
new UpsertPointExchangeItemDto
|
||||||
|
{
|
||||||
|
ItemKey = "admin_svip_7d",
|
||||||
|
Name = "后台 SVIP 7 天",
|
||||||
|
ItemType = PointExchangeItemType.Entitlement,
|
||||||
|
PointsCost = 30,
|
||||||
|
Days = 7,
|
||||||
|
Stock = 10
|
||||||
|
});
|
||||||
|
var exchangeItemsResponse = await client.GetAsync("/api/tenant-commerce/point-exchange-items");
|
||||||
|
var couponResponse = await client.PutAsJsonAsync(
|
||||||
|
"/api/tenant-commerce/coupons",
|
||||||
|
new UpsertTenantCouponDto
|
||||||
|
{
|
||||||
|
Code = "ADMIN10",
|
||||||
|
DiscountType = DiscountType.Fixed,
|
||||||
|
DiscountValue = 10,
|
||||||
|
MaxUses = 100
|
||||||
|
});
|
||||||
|
var couponsResponse = await client.GetAsync("/api/tenant-commerce/coupons");
|
||||||
|
var reportResponse = await client.GetAsync("/api/tenant-commerce/coupons/report");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, taskResponse.StatusCode);
|
||||||
|
Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, exchangeItemResponse.StatusCode);
|
||||||
|
Assert.Contains("admin_svip_7d", await exchangeItemsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, couponResponse.StatusCode);
|
||||||
|
Assert.Contains("ADMIN10", await couponsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
|
private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
|
||||||
{
|
{
|
||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
|
|||||||
Reference in New Issue
Block a user