diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs index cc7dbeb..2f87b46 100644 --- a/Tiku.Api/Contracts/TenantCommerceDtos.cs +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index 13eb82a..c76d960 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -119,6 +119,149 @@ public sealed class TenantCommerceController( cancellationToken)); } + [HttpGet("point-activity-tasks")] + [EndpointSummary("查询积分活动任务")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PointTasks( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPointTasksAsync( + ResolveActor(), + ToPointQuery(query), + cancellationToken)); + } + + [HttpPut("point-activity-tasks")] + [EndpointSummary("新增或更新积分活动任务")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertPointTask( + UpsertPointTaskDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpsertPointTaskAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("point-activity-claims")] + [EndpointSummary("查询积分任务领取记录")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PointClaims( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPointClaimsAsync( + ResolveActor(), + ToPointQuery(query), + cancellationToken)); + } + + [HttpGet("point-exchange-items")] + [EndpointSummary("查询积分兑换项")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PointExchangeItems( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPointExchangeItemsAsync( + ResolveActor(), + ToPointQuery(query), + cancellationToken)); + } + + [HttpPut("point-exchange-items")] + [EndpointSummary("新增或更新积分兑换项")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertPointExchangeItem( + UpsertPointExchangeItemDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpsertPointExchangeItemAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("point-exchange-orders")] + [EndpointSummary("查询积分兑换订单")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PointExchangeOrders( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetPointExchangeOrdersAsync( + ResolveActor(), + ToPointQuery(query), + cancellationToken)); + } + + [HttpPost("point-exchange-orders/status")] + [EndpointSummary("更新积分兑换订单状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdatePointExchangeOrderStatus( + UpdatePointExchangeOrderStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpdatePointExchangeOrderStatusAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("coupons")] + [EndpointSummary("查询租户优惠券")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Coupons( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetCouponsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPut("coupons")] + [EndpointSummary("新增或更新租户优惠券")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertCoupon( + UpsertTenantCouponDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpsertCouponAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("coupons/redemptions")] + [EndpointSummary("查询优惠券领取和核销记录")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CouponRedemptions( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetCouponRedemptionsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpGet("coupons/report")] + [EndpointSummary("查询优惠券基础报表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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); + } } diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs index bbfe62a..2e6e30c 100644 --- a/Tiku.Application/Commerce/CommerceAdminModels.cs +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -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 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 Items); +public sealed record TenantPointClaimList(IReadOnlyCollection Items); +public sealed record TenantPointExchangeItemList(IReadOnlyCollection Items); +public sealed record TenantPointExchangeOrderList(IReadOnlyCollection 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 Items); +public sealed record TenantCouponRedemptionList(IReadOnlyCollection Items); +public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int UsedCount, int DiscountAppliedCents); + public interface ICommerceAdminService { Task> GetPaymentAccountsAsync( @@ -132,4 +189,59 @@ public interface ICommerceAdminService CommerceAdminActor actor, RedeemActivationCodeCommand command, CancellationToken cancellationToken = default); + + Task GetPointTasksAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default); + + Task UpsertPointTaskAsync( + CommerceAdminActor actor, + UpsertPointTaskCommand command, + CancellationToken cancellationToken = default); + + Task GetPointClaimsAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default); + + Task GetPointExchangeItemsAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default); + + Task UpsertPointExchangeItemAsync( + CommerceAdminActor actor, + UpsertPointExchangeItemCommand command, + CancellationToken cancellationToken = default); + + Task GetPointExchangeOrdersAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default); + + Task UpdatePointExchangeOrderStatusAsync( + CommerceAdminActor actor, + UpdatePointExchangeOrderStatusCommand command, + CancellationToken cancellationToken = default); + + Task GetCouponsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task UpsertCouponAsync( + CommerceAdminActor actor, + UpsertCouponCommand command, + CancellationToken cancellationToken = default); + + Task GetCouponRedemptionsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task GetCouponReportAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index 67f3ee3..6ada728 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -277,6 +277,291 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm return ToActivationCodeItem(code); } + public async Task 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 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 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 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 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 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 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 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 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 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 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(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(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(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(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)); diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index cd8a331..f292d04 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -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 SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) { var tenantId = Guid.NewGuid();