455 lines
18 KiB
C#
455 lines
18 KiB
C#
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.Learning;
|
|
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);
|
|
var balanceAfter = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints + task.Points;
|
|
dbContext.UserScoreEvents.Add(new UserScoreEvent
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
EventType = task.TaskType == PointActivityTaskType.DailyLogin
|
|
? UserScoreEventType.CheckIn
|
|
: UserScoreEventType.ActivityReward,
|
|
Points = task.Points,
|
|
BalanceAfter = balanceAfter,
|
|
SourceType = NormalizeOptional(command.SourceType) ?? "point_task",
|
|
SourceId = command.SourceId ?? claim.Id,
|
|
IdempotencyKey = $"point-claim:{actor.TenantId:N}:{claim.Id:N}",
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
task.Id,
|
|
task.TaskKey
|
|
})
|
|
});
|
|
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);
|
|
dbContext.UserScoreEvents.Add(new UserScoreEvent
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
EventType = UserScoreEventType.RedeemCost,
|
|
Points = -item.PointsCost,
|
|
BalanceAfter = balance - item.PointsCost,
|
|
SourceType = "point_exchange_order",
|
|
SourceId = order.Id,
|
|
IdempotencyKey = $"point-exchange:{actor.TenantId:N}:{order.Id:N}",
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
item.Id,
|
|
item.ItemKey,
|
|
item.Name
|
|
})
|
|
});
|
|
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)
|
|
{
|
|
return 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)}");
|
|
}
|
|
} |