From a4621df7286567461e5e71660c51d3a736fb9509 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 19:59:20 +0800 Subject: [PATCH] feat: add student points endpoints --- Tiku.Api/Contracts/PointDtos.cs | 48 ++ Tiku.Api/Controllers/PointsController.cs | 100 ++++ .../Middleware/ExceptionHandlingMiddleware.cs | 23 + Tiku.Application/Points/PointModels.cs | 107 +++++ Tiku.Infrastructure/DependencyInjection.cs | 3 + Tiku.Infrastructure/Points/PointService.cs | 444 ++++++++++++++++++ .../Api/PointsEndpointTests.cs | 292 ++++++++++++ 7 files changed, 1017 insertions(+) create mode 100644 Tiku.Api/Contracts/PointDtos.cs create mode 100644 Tiku.Api/Controllers/PointsController.cs create mode 100644 Tiku.Application/Points/PointModels.cs create mode 100644 Tiku.Infrastructure/Points/PointService.cs create mode 100644 Tiku.IntegrationTests/Api/PointsEndpointTests.cs diff --git a/Tiku.Api/Contracts/PointDtos.cs b/Tiku.Api/Contracts/PointDtos.cs new file mode 100644 index 0000000..d52512b --- /dev/null +++ b/Tiku.Api/Contracts/PointDtos.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Tiku.Application.Points; + +namespace Tiku.Api.Contracts; + +public sealed class PointQueryDto +{ + [Range(1, 200)] + public int? Limit { get; set; } + + [StringLength(32)] + public string? Status { get; set; } + + public Guid? RegionId { get; set; } + + public PointLimitQuery ToQuery() + { + return new PointLimitQuery(Limit, Status, RegionId); + } +} + +public sealed class ClaimPointTaskDto +{ + [Required] + [StringLength(100)] + public string TaskKey { get; set; } = string.Empty; + + [StringLength(100)] + public string? SourceType { get; set; } + + public Guid? SourceId { get; set; } + + public ClaimPointTaskCommand ToCommand() + { + return new ClaimPointTaskCommand(TaskKey, SourceType, SourceId); + } +} + +public sealed class CreatePointExchangeOrderDto +{ + [Required] + public Guid ItemId { get; set; } + + public CreatePointExchangeOrderCommand ToCommand() + { + return new CreatePointExchangeOrderCommand(ItemId); + } +} diff --git a/Tiku.Api/Controllers/PointsController.cs b/Tiku.Api/Controllers/PointsController.cs new file mode 100644 index 0000000..3577d6a --- /dev/null +++ b/Tiku.Api/Controllers/PointsController.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Points; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[Produces("application/json")] +[Route("api/points")] +public sealed class PointsController( + IPointService pointService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpGet("summary")] + [EndpointSummary("查询当前用户积分摘要")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Summary(CancellationToken cancellationToken) + { + return Ok(await pointService.GetSummaryAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("tasks")] + [EndpointSummary("查询当前可领取积分任务")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> Tasks( + [FromQuery] PointQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await pointService.GetTasksAsync( + ResolveActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpPost("tasks/claim")] + [EndpointSummary("领取积分任务奖励")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ClaimTask( + ClaimPointTaskDto request, + CancellationToken cancellationToken) + { + return Ok(await pointService.ClaimTaskAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("exchange-items")] + [EndpointSummary("查询积分兑换项")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> ExchangeItems( + [FromQuery] PointQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await pointService.GetExchangeItemsAsync( + ResolveActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpPost("exchange-orders")] + [EndpointSummary("创建积分兑换订单")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateExchangeOrder( + CreatePointExchangeOrderDto request, + CancellationToken cancellationToken) + { + return Ok(await pointService.CreateExchangeOrderAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("exchange-orders")] + [EndpointSummary("查询当前用户积分兑换订单")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> ExchangeOrders( + [FromQuery] PointQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await pointService.GetExchangeOrdersAsync( + ResolveActor(), + query.ToQuery(), + cancellationToken)); + } + + private PointActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new PointException("Current point actor was not resolved.", "point_access_denied"); + } + + return new PointActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 0948de3..1e89780 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -4,6 +4,7 @@ using Tiku.Application.Assets; using Tiku.Application.Auth; using Tiku.Application.Commerce; using Tiku.Application.Content; +using Tiku.Application.Points; using Tiku.Application.Storage; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Learning; @@ -183,6 +184,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is PointException pointException) + { + await WriteProblemAsync( + context, + pointException.Message, + PointStatusCode(pointException.Code), + pointException.Code); + return; + } + if (exception is PaymentProviderException paymentProviderException) { await WriteProblemAsync( @@ -342,4 +353,16 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } + + private static int PointStatusCode(string code) + { + return code switch + { + "point_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + "point_task_not_found" or "point_exchange_item_not_found" => StatusCodes.Status404NotFound, + "point_task_claim_limit_reached" or "insufficient_points" or "point_exchange_item_sold_out" => + StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Points/PointModels.cs b/Tiku.Application/Points/PointModels.cs new file mode 100644 index 0000000..9b115d6 --- /dev/null +++ b/Tiku.Application/Points/PointModels.cs @@ -0,0 +1,107 @@ +using System.Text.Json; + +namespace Tiku.Application.Points; + +public sealed record PointActor(Guid TenantId, Guid UserId); + +public sealed record PointLimitQuery(int? Limit = null, string? Status = null, Guid? RegionId = null); + +public sealed record ClaimPointTaskCommand(string TaskKey, string? SourceType, Guid? SourceId); + +public sealed record CreatePointExchangeOrderCommand(Guid ItemId); + +public sealed record PointSummaryItem(int EarnedPoints, int SpentPoints, int BalancePoints); + +public sealed record PointTaskItem( + Guid Id, + string TaskKey, + string Title, + string? Description, + string TaskType, + string Status, + int Points, + int MaxClaimsPerUser, + int ClaimedCount, + bool CanClaim, + DateTimeOffset? StartsAt, + DateTimeOffset? EndsAt, + int SortOrder, + JsonElement Rules, + JsonElement Metadata); + +public sealed record PointClaimItem( + Guid Id, + Guid TaskId, + string TaskKey, + int Points, + string Status, + string? SourceType, + Guid? SourceId, + DateTimeOffset ClaimedAt, + JsonElement Metadata); + +public sealed record PointExchangeItemDto( + Guid Id, + Guid? RegionId, + string ItemKey, + string Name, + string? Description, + string ItemType, + string Status, + int PointsCost, + int? Stock, + int? Days, + bool CanExchange, + int SortOrder, + JsonElement FulfillmentPayload, + JsonElement Metadata); + +public sealed record PointExchangeOrderItem( + Guid Id, + Guid ItemId, + string OrderNo, + string ItemName, + string ItemType, + string Status, + int PointsCost, + DateTimeOffset OrderedAt, + DateTimeOffset? CompletedAt, + JsonElement FulfillmentSnapshot, + JsonElement Metadata); + +public sealed record PointList(IReadOnlyCollection Items); + +public interface IPointService +{ + Task GetSummaryAsync(PointActor actor, CancellationToken cancellationToken = default); + + Task> GetTasksAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default); + + Task ClaimTaskAsync( + PointActor actor, + ClaimPointTaskCommand command, + CancellationToken cancellationToken = default); + + Task> GetExchangeItemsAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default); + + Task CreateExchangeOrderAsync( + PointActor actor, + CreatePointExchangeOrderCommand command, + CancellationToken cancellationToken = default); + + Task> GetExchangeOrdersAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default); +} + +public sealed class PointException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 6cf8b84..539699c 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -9,6 +9,7 @@ using Tiku.Application.Content; using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Profile; +using Tiku.Application.Points; using Tiku.Application.Scoreline; using Tiku.Application.Storage; using Tiku.Application.StudyContent; @@ -21,6 +22,7 @@ using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Learning; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Profile; +using Tiku.Infrastructure.Points; using Tiku.Infrastructure.QuestionBanks; using Tiku.Infrastructure.Scoreline; using Tiku.Infrastructure.Storage; @@ -65,6 +67,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Points/PointService.cs b/Tiku.Infrastructure/Points/PointService.cs new file mode 100644 index 0000000..1221e1e --- /dev/null +++ b/Tiku.Infrastructure/Points/PointService.cs @@ -0,0 +1,444 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Points; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Points; + +public sealed class PointService(TikuDbContext dbContext) : IPointService +{ + public async Task GetSummaryAsync( + PointActor actor, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + return await GetSummaryCoreAsync(actor, cancellationToken); + } + + public async Task> GetTasksAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var now = DateTimeOffset.UtcNow; + var limit = Math.Clamp(query.Limit ?? 50, 1, 200); + var tasks = await dbContext.PointActivityTasks + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.Status == PointActivityTaskStatus.Active && + (item.StartsAt == null || item.StartsAt <= now) && + (item.EndsAt == null || item.EndsAt > now)) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(limit) + .ToArrayAsync(cancellationToken); + var taskIds = tasks.Select(item => item.Id).ToArray(); + var claimCounts = await dbContext.PointActivityClaims + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == PointActivityClaimStatus.Claimed && + taskIds.Contains(item.TaskId)) + .GroupBy(item => item.TaskId) + .Select(group => new { TaskId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.TaskId, item => item.Count, cancellationToken); + + return new PointList( + tasks.Select(task => + { + var claimedCount = claimCounts.GetValueOrDefault(task.Id); + return ToTaskItem(task, claimedCount); + }).ToArray()); + } + + public async Task ClaimTaskAsync( + PointActor actor, + ClaimPointTaskCommand command, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var taskKey = NormalizeKey(command.TaskKey, "task_key_required"); + var now = DateTimeOffset.UtcNow; + var task = await dbContext.PointActivityTasks + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.TaskKey == taskKey, + cancellationToken) + ?? throw new PointException("Point activity task was not found.", "point_task_not_found"); + if (task.Status != PointActivityTaskStatus.Active || + task.StartsAt is not null && task.StartsAt > now || + task.EndsAt is not null && task.EndsAt <= now) + { + throw new PointException("Point activity task is not claimable.", "point_task_inactive"); + } + + if (!string.IsNullOrWhiteSpace(command.SourceType) && command.SourceId.HasValue) + { + var sourceType = NormalizeOptional(command.SourceType); + var existing = await dbContext.PointActivityClaims + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.TaskId == task.Id && + item.SourceType == sourceType && + item.SourceId == command.SourceId.Value && + item.Status == PointActivityClaimStatus.Claimed) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (existing is not null) + { + return ToClaimItem(existing); + } + } + + var claimedCount = await dbContext.PointActivityClaims.CountAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.TaskId == task.Id && + item.Status == PointActivityClaimStatus.Claimed, + cancellationToken); + if (claimedCount >= task.MaxClaimsPerUser) + { + throw new PointException("Point activity task claim limit has been reached.", "point_task_claim_limit_reached"); + } + + var claim = new PointActivityClaim + { + TenantId = actor.TenantId, + TaskId = task.Id, + UserId = actor.UserId, + TaskKey = task.TaskKey, + Points = task.Points, + Status = PointActivityClaimStatus.Claimed, + SourceType = NormalizeOptional(command.SourceType), + SourceId = command.SourceId, + ClaimedAt = now, + Metadata = JsonSerializer.SerializeToElement(new { source = "student_points" }) + }; + dbContext.PointActivityClaims.Add(claim); + await dbContext.SaveChangesAsync(cancellationToken); + return ToClaimItem(claim); + } + + public async Task> GetExchangeItemsAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var now = DateTimeOffset.UtcNow; + var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; + var items = dbContext.PointExchangeItems + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.Status == PointExchangeItemStatus.Active && + (item.StartsAt == null || item.StartsAt <= now) && + (item.EndsAt == null || item.EndsAt > now)); + if (query.RegionId.HasValue) + { + items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); + } + + var result = await items + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.PointsCost) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new PointList(result.Select(item => ToExchangeItem(item, balance)).ToArray()); + } + + public async Task CreateExchangeOrderAsync( + PointActor actor, + CreatePointExchangeOrderCommand command, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var now = DateTimeOffset.UtcNow; + var item = await dbContext.PointExchangeItems + .SingleOrDefaultAsync(entry => + entry.TenantId == actor.TenantId && + entry.Id == command.ItemId, + cancellationToken) + ?? throw new PointException("Point exchange item was not found.", "point_exchange_item_not_found"); + if (item.Status != PointExchangeItemStatus.Active || + item.StartsAt is not null && item.StartsAt > now || + item.EndsAt is not null && item.EndsAt <= now) + { + throw new PointException("Point exchange item is not available.", "point_exchange_item_inactive"); + } + + if (item.Stock is <= 0) + { + throw new PointException("Point exchange item is sold out.", "point_exchange_item_sold_out"); + } + + var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; + if (balance < item.PointsCost) + { + throw new PointException("Point balance is insufficient.", "insufficient_points"); + } + + if (item.Stock.HasValue) + { + item.Stock -= 1; + } + + var order = new PointExchangeOrder + { + TenantId = actor.TenantId, + ItemId = item.Id, + UserId = actor.UserId, + OrderNo = GenerateOrderNo(), + ItemName = item.Name, + ItemType = item.ItemType, + Status = PointExchangeOrderStatus.Completed, + PointsCost = item.PointsCost, + OrderedAt = now, + CompletedAt = now, + FulfillmentSnapshot = item.FulfillmentPayload, + Metadata = JsonSerializer.SerializeToElement(new { source = "student_points_exchange" }) + }; + dbContext.PointExchangeOrders.Add(order); + if (item.ItemType == PointExchangeItemType.Entitlement) + { + await GrantEntitlementAsync(actor, item, order, now, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return ToExchangeOrderItem(order); + } + + public async Task> GetExchangeOrdersAsync( + PointActor actor, + PointLimitQuery query, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var orders = dbContext.PointExchangeOrders + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + orders = orders.Where(item => item.Status == ParseExchangeOrderStatus(query.Status)); + } + + var result = await orders + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new PointList(result.Select(ToExchangeOrderItem).ToArray()); + } + + private async Task GetSummaryCoreAsync( + PointActor actor, + CancellationToken cancellationToken) + { + var earned = await dbContext.PointActivityClaims + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == PointActivityClaimStatus.Claimed) + .SumAsync(item => (int?)item.Points, cancellationToken) ?? 0; + var spent = await dbContext.PointExchangeOrders + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == PointExchangeOrderStatus.Completed) + .SumAsync(item => (int?)item.PointsCost, cancellationToken) ?? 0; + return new PointSummaryItem(earned, spent, earned - spent); + } + + private async Task GrantEntitlementAsync( + PointActor actor, + PointExchangeItem item, + PointExchangeOrder order, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var days = item.Days ?? ReadInt(item.FulfillmentPayload, "days") ?? 0; + var current = await dbContext.Entitlements + .Where(entry => + entry.TenantId == actor.TenantId && + entry.UserId == actor.UserId && + entry.EntitlementType == "svip" && + entry.Status == EntitlementStatus.Active) + .OrderByDescending(entry => entry.ExpiresAt) + .FirstOrDefaultAsync(cancellationToken); + if (current is null) + { + dbContext.Entitlements.Add(new Entitlement + { + TenantId = actor.TenantId, + UserId = actor.UserId, + EntitlementType = "svip", + ScopeType = EntitlementScopeType.Tenant, + SourceType = "point_exchange_order", + SourceId = order.Id, + StartsAt = now, + ExpiresAt = days > 0 ? now.AddDays(days) : null, + Status = EntitlementStatus.Active, + Metadata = item.FulfillmentPayload + }); + } + else if (days > 0) + { + var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > now + ? current.ExpiresAt.Value + : now; + current.ExpiresAt = baseAt.AddDays(days); + current.Metadata = item.FulfillmentPayload; + } + } + + private async Task AssertActiveMemberAsync(PointActor actor, CancellationToken cancellationToken) + { + var exists = await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active, + cancellationToken); + if (!exists) + { + throw new PointException("Current user is not a member of the tenant.", "tenant_access_denied"); + } + } + + private static PointTaskItem ToTaskItem(PointActivityTask task, int claimedCount) + { + return new PointTaskItem( + task.Id, + task.TaskKey, + task.Title, + task.Description, + task.TaskType.ToString(), + task.Status.ToString(), + task.Points, + task.MaxClaimsPerUser, + claimedCount, + claimedCount < task.MaxClaimsPerUser, + task.StartsAt, + task.EndsAt, + task.SortOrder, + task.Rules, + task.Metadata); + } + + private static PointClaimItem ToClaimItem(PointActivityClaim claim) + { + return new PointClaimItem( + claim.Id, + claim.TaskId, + claim.TaskKey, + claim.Points, + claim.Status.ToString(), + claim.SourceType, + claim.SourceId, + claim.ClaimedAt, + claim.Metadata); + } + + private static PointExchangeItemDto ToExchangeItem(PointExchangeItem item, int balance) + { + return new PointExchangeItemDto( + item.Id, + item.RegionId, + item.ItemKey, + item.Name, + item.Description, + item.ItemType.ToString(), + item.Status.ToString(), + item.PointsCost, + item.Stock, + item.Days, + balance >= item.PointsCost && item.Stock is not <= 0, + item.SortOrder, + item.FulfillmentPayload, + item.Metadata); + } + + private static PointExchangeOrderItem ToExchangeOrderItem(PointExchangeOrder order) + { + return new PointExchangeOrderItem( + order.Id, + order.ItemId, + order.OrderNo, + order.ItemName, + order.ItemType.ToString(), + order.Status.ToString(), + order.PointsCost, + order.OrderedAt, + order.CompletedAt, + order.FulfillmentSnapshot, + order.Metadata); + } + + private static PointExchangeOrderStatus ParseExchangeOrderStatus(string? status) + { + return Enum.TryParse( + NormalizeEnum(status), + true, + out var parsed) + ? parsed + : throw new PointException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); + } + + private static string NormalizeEnum(string? value) => + string.Concat((value ?? string.Empty).Split( + ['_', '-', ' '], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + private static string NormalizeKey(string? value, string code) + { + var normalized = NormalizeOptional(value); + return !string.IsNullOrWhiteSpace(normalized) + ? normalized + : throw new PointException("Required point key is missing.", code); + } + + private static string? NormalizeOptional(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private static int? ReadInt(JsonElement payload, string propertyName) + { + if (payload.ValueKind != JsonValueKind.Object || + !payload.TryGetProperty(propertyName, out var property)) + { + return null; + } + + return property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value) + ? value + : null; + } + + private static string GenerateOrderNo() + { + Span bytes = stackalloc byte[6]; + RandomNumberGenerator.Fill(bytes); + return string.Create( + CultureInfo.InvariantCulture, + $"PX{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"); + } +} diff --git a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs new file mode 100644 index 0000000..3422f99 --- /dev/null +++ b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs @@ -0,0 +1,292 @@ +using System.Net; +using System.Net.Http.Json; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Application.Auth; +using Tiku.Application.Points; +using Tiku.Application.Security; +using Tiku.Api.Options; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class PointsEndpointTests +{ + private static readonly JwtOptions JwtOptions = new() + { + Issuer = "tiku-backend", + Audience = "tiku-api", + SigningKey = "development-only-tiku-signing-key-change-before-production" + }; + + [Fact] + public async Task Anonymous_points_request_returns_401() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/points/summary"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Non_member_cannot_access_points() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory, includeMembership: false); + var sessionId = Guid.NewGuid(); + await factory.SeedAsync(new AuthSession + { + Id = sessionId, + TenantId = seed.TenantId, + UserId = seed.UserId, + TokenHash = "integration-test-token-hash", + Provider = "test", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + CreateToken([ + new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), + new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()), + new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + ])); + + var response = await client.GetAsync("/api/points/summary"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Student_can_claim_task_idempotently_and_query_summary() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var sourceId = Guid.NewGuid(); + var first = await client.PostAsJsonAsync( + "/api/points/tasks/claim", + new ClaimPointTaskDto + { + TaskKey = "daily_login", + SourceType = "daily", + SourceId = sourceId + }); + var second = await client.PostAsJsonAsync( + "/api/points/tasks/claim", + new ClaimPointTaskDto + { + TaskKey = "daily_login", + SourceType = "daily", + SourceId = sourceId + }); + var firstClaim = await first.Content.ReadFromJsonAsync(); + var secondClaim = await second.Content.ReadFromJsonAsync(); + var summary = await (await client.GetAsync("/api/points/summary")) + .Content + .ReadFromJsonAsync(); + var tasks = await (await client.GetAsync("/api/points/tasks")) + .Content + .ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(firstClaim!.Id, secondClaim!.Id); + Assert.Equal(20, summary!.EarnedPoints); + Assert.Equal(20, summary.BalancePoints); + Assert.Contains(tasks!.Items, item => item.TaskKey == "daily_login" && !item.CanClaim); + } + + [Fact] + public async Task Exchange_requires_enough_points() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var response = await client.PostAsJsonAsync( + "/api/points/exchange-orders", + new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Student_can_exchange_points_for_entitlement() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + await client.PostAsJsonAsync( + "/api/points/tasks/claim", + new ClaimPointTaskDto { TaskKey = "practice_reward", SourceType = "practice", SourceId = Guid.NewGuid() }); + + var items = await (await client.GetAsync("/api/points/exchange-items")) + .Content + .ReadFromJsonAsync>(); + var response = await client.PostAsJsonAsync( + "/api/points/exchange-orders", + new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); + var order = await response.Content.ReadFromJsonAsync(); + var orders = await (await client.GetAsync("/api/points/exchange-orders")) + .Content + .ReadFromJsonAsync>(); + var summary = await (await client.GetAsync("/api/points/summary")) + .Content + .ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(items!.Items.Single(item => item.Id == seed.ExchangeItemId).CanExchange); + Assert.Equal("Completed", order!.Status); + Assert.Contains(orders!.Items, item => item.Id == order.Id); + Assert.Equal(100, summary!.EarnedPoints); + Assert.Equal(30, summary.SpentPoints); + Assert.Equal(70, summary.BalancePoints); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Contains(dbContext.Entitlements, item => + item.TenantId == seed.TenantId && + item.UserId == seed.UserId && + item.SourceType == "point_exchange_order"); + } + + private static async Task SeedLoginUserAsync( + ApiTestFactory factory, + bool includeMembership = true) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var exchangeItemId = Guid.NewGuid(); + var phone = "13800000001"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + var entities = new List + { + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Points Tenant" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Points User" + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }, + new PointActivityTask + { + TenantId = tenantId, + TaskKey = "daily_login", + Title = "每日登录", + TaskType = PointActivityTaskType.DailyLogin, + Points = 20, + MaxClaimsPerUser = 1, + SortOrder = 1 + }, + new PointActivityTask + { + TenantId = tenantId, + TaskKey = "practice_reward", + Title = "练习奖励", + TaskType = PointActivityTaskType.Practice, + Points = 100, + MaxClaimsPerUser = 3, + SortOrder = 2 + }, + new PointExchangeItem + { + Id = exchangeItemId, + TenantId = tenantId, + ItemKey = "svip_7d", + Name = "SVIP 7 天", + ItemType = PointExchangeItemType.Entitlement, + PointsCost = 30, + Days = 7, + Stock = 5, + SortOrder = 1 + } + }; + if (includeMembership) + { + entities.Add(new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + } + + await factory.SeedAsync(entities.ToArray()); + return new PointSeed(tenantId, userId, exchangeItemId, phone); + } + + private static async Task LoginAsync(HttpClient client, PointSeed seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + loginResponse.EnsureSuccessStatusCode(); + using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } + + private static string CreateToken(IEnumerable claims) + { + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + JwtOptions.Issuer, + JwtOptions.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private sealed record PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone); +}