forked from xiongyuxing/tiku-backend.net
feat: add student points endpoints
This commit is contained in:
48
Tiku.Api/Contracts/PointDtos.cs
Normal file
48
Tiku.Api/Contracts/PointDtos.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Tiku.Api/Controllers/PointsController.cs
Normal file
100
Tiku.Api/Controllers/PointsController.cs
Normal file
@@ -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<PointSummaryItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointSummaryItem>> Summary(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await pointService.GetSummaryAsync(ResolveActor(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks")]
|
||||||
|
[EndpointSummary("查询当前可领取积分任务")]
|
||||||
|
[ProducesResponseType<PointList<PointTaskItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointList<PointTaskItem>>> Tasks(
|
||||||
|
[FromQuery] PointQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await pointService.GetTasksAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/claim")]
|
||||||
|
[EndpointSummary("领取积分任务奖励")]
|
||||||
|
[ProducesResponseType<PointClaimItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointClaimItem>> ClaimTask(
|
||||||
|
ClaimPointTaskDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await pointService.ClaimTaskAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("exchange-items")]
|
||||||
|
[EndpointSummary("查询积分兑换项")]
|
||||||
|
[ProducesResponseType<PointList<PointExchangeItemDto>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointList<PointExchangeItemDto>>> ExchangeItems(
|
||||||
|
[FromQuery] PointQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await pointService.GetExchangeItemsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("exchange-orders")]
|
||||||
|
[EndpointSummary("创建积分兑换订单")]
|
||||||
|
[ProducesResponseType<PointExchangeOrderItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointExchangeOrderItem>> CreateExchangeOrder(
|
||||||
|
CreatePointExchangeOrderDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await pointService.CreateExchangeOrderAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("exchange-orders")]
|
||||||
|
[EndpointSummary("查询当前用户积分兑换订单")]
|
||||||
|
[ProducesResponseType<PointList<PointExchangeOrderItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PointList<PointExchangeOrderItem>>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Tiku.Application.Assets;
|
|||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
|
using Tiku.Application.Points;
|
||||||
using Tiku.Application.Storage;
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Learning;
|
using Tiku.Infrastructure.Learning;
|
||||||
@@ -183,6 +184,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is PointException pointException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
pointException.Message,
|
||||||
|
PointStatusCode(pointException.Code),
|
||||||
|
pointException.Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is PaymentProviderException paymentProviderException)
|
if (exception is PaymentProviderException paymentProviderException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
@@ -342,4 +353,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
_ => StatusCodes.Status400BadRequest
|
_ => 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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
Tiku.Application/Points/PointModels.cs
Normal file
107
Tiku.Application/Points/PointModels.cs
Normal file
@@ -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<T>(IReadOnlyCollection<T> Items);
|
||||||
|
|
||||||
|
public interface IPointService
|
||||||
|
{
|
||||||
|
Task<PointSummaryItem> GetSummaryAsync(PointActor actor, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointList<PointTaskItem>> GetTasksAsync(
|
||||||
|
PointActor actor,
|
||||||
|
PointLimitQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointClaimItem> ClaimTaskAsync(
|
||||||
|
PointActor actor,
|
||||||
|
ClaimPointTaskCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointList<PointExchangeItemDto>> GetExchangeItemsAsync(
|
||||||
|
PointActor actor,
|
||||||
|
PointLimitQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointExchangeOrderItem> CreateExchangeOrderAsync(
|
||||||
|
PointActor actor,
|
||||||
|
CreatePointExchangeOrderCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PointList<PointExchangeOrderItem>> GetExchangeOrdersAsync(
|
||||||
|
PointActor actor,
|
||||||
|
PointLimitQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PointException(string message, string code) : Exception(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ using Tiku.Application.Content;
|
|||||||
using Tiku.Application.Learning;
|
using Tiku.Application.Learning;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
using Tiku.Application.Profile;
|
using Tiku.Application.Profile;
|
||||||
|
using Tiku.Application.Points;
|
||||||
using Tiku.Application.Scoreline;
|
using Tiku.Application.Scoreline;
|
||||||
using Tiku.Application.Storage;
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Application.StudyContent;
|
using Tiku.Application.StudyContent;
|
||||||
@@ -21,6 +22,7 @@ using Tiku.Infrastructure.Content;
|
|||||||
using Tiku.Infrastructure.Learning;
|
using Tiku.Infrastructure.Learning;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.Profile;
|
using Tiku.Infrastructure.Profile;
|
||||||
|
using Tiku.Infrastructure.Points;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Scoreline;
|
using Tiku.Infrastructure.Scoreline;
|
||||||
using Tiku.Infrastructure.Storage;
|
using Tiku.Infrastructure.Storage;
|
||||||
@@ -65,6 +67,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||||
services.AddScoped<ICommerceService, CommerceService>();
|
services.AddScoped<ICommerceService, CommerceService>();
|
||||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||||
|
services.AddScoped<IPointService, PointService>();
|
||||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||||
|
|||||||
444
Tiku.Infrastructure/Points/PointService.cs
Normal file
444
Tiku.Infrastructure/Points/PointService.cs
Normal file
@@ -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<PointSummaryItem> GetSummaryAsync(
|
||||||
|
PointActor actor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||||
|
return await GetSummaryCoreAsync(actor, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointList<PointTaskItem>> 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<PointTaskItem>(
|
||||||
|
tasks.Select(task =>
|
||||||
|
{
|
||||||
|
var claimedCount = claimCounts.GetValueOrDefault(task.Id);
|
||||||
|
return ToTaskItem(task, claimedCount);
|
||||||
|
}).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointClaimItem> 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<PointList<PointExchangeItemDto>> 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<PointExchangeItemDto>(result.Select(item => ToExchangeItem(item, balance)).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PointExchangeOrderItem> 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<PointList<PointExchangeOrderItem>> 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<PointExchangeOrderItem>(result.Select(ToExchangeOrderItem).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PointSummaryItem> 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<PointExchangeOrderStatus>(
|
||||||
|
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<byte> bytes = stackalloc byte[6];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"PX{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
292
Tiku.IntegrationTests/Api/PointsEndpointTests.cs
Normal file
292
Tiku.IntegrationTests/Api/PointsEndpointTests.cs
Normal file
@@ -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<PointClaimItem>();
|
||||||
|
var secondClaim = await second.Content.ReadFromJsonAsync<PointClaimItem>();
|
||||||
|
var summary = await (await client.GetAsync("/api/points/summary"))
|
||||||
|
.Content
|
||||||
|
.ReadFromJsonAsync<PointSummaryItem>();
|
||||||
|
var tasks = await (await client.GetAsync("/api/points/tasks"))
|
||||||
|
.Content
|
||||||
|
.ReadFromJsonAsync<PointList<PointTaskItem>>();
|
||||||
|
|
||||||
|
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<PointList<PointExchangeItemDto>>();
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/api/points/exchange-orders",
|
||||||
|
new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId });
|
||||||
|
var order = await response.Content.ReadFromJsonAsync<PointExchangeOrderItem>();
|
||||||
|
var orders = await (await client.GetAsync("/api/points/exchange-orders"))
|
||||||
|
.Content
|
||||||
|
.ReadFromJsonAsync<PointList<PointExchangeOrderItem>>();
|
||||||
|
var summary = await (await client.GetAsync("/api/points/summary"))
|
||||||
|
.Content
|
||||||
|
.ReadFromJsonAsync<PointSummaryItem>();
|
||||||
|
|
||||||
|
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<TikuDbContext>();
|
||||||
|
Assert.Contains(dbContext.Entitlements, item =>
|
||||||
|
item.TenantId == seed.TenantId &&
|
||||||
|
item.UserId == seed.UserId &&
|
||||||
|
item.SourceType == "point_exchange_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PointSeed> 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<object>
|
||||||
|
{
|
||||||
|
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<Claim> 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user