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.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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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.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<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
services.AddScoped<IPointService, PointService>();
|
||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||
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