forked from xiongyuxing/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)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
public Guid? RegionId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpsertPaymentAccountDto
|
||||
@@ -134,3 +138,162 @@ public sealed class RedeemActivationCodeDto
|
||||
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));
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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 TenantPointQuery(string? Status = null, int? Limit = null, Guid? UserId = null, Guid? RegionId = null);
|
||||
|
||||
public sealed record UpsertPaymentAccountCommand(
|
||||
string Provider,
|
||||
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 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
|
||||
{
|
||||
Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
||||
@@ -132,4 +189,59 @@ public interface ICommerceAdminService
|
||||
CommerceAdminActor actor,
|
||||
RedeemActivationCodeCommand command,
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
@@ -321,6 +606,26 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
||||
? parsed
|
||||
: 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) =>
|
||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
|
||||
@@ -135,6 +135,58 @@ public sealed class TenantCommerceEndpointTests
|
||||
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)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
Reference in New Issue
Block a user