feat: add student points endpoints

This commit is contained in:
xiong
2026-07-26 19:59:20 +08:00
parent 77cac70344
commit a4621df728
7 changed files with 1017 additions and 0 deletions

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

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

View File

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

View 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;
}

View File

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

View 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)}");
}
}

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