feat: add tenant points and coupon operations

This commit is contained in:
xiong
2026-07-26 20:08:23 +08:00
parent 7139da766a
commit 4efccf8355
5 changed files with 780 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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