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 GetSummaryAsync( PointActor actor, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); return await GetSummaryCoreAsync(actor, cancellationToken); } public async Task> GetTasksAsync( PointActor actor, PointLimitQuery query, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var limit = Math.Clamp(query.Limit ?? 50, 1, 200); var tasks = await dbContext.PointActivityTasks .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityTaskStatus.Active && (item.StartsAt == null || item.StartsAt <= now) && (item.EndsAt == null || item.EndsAt > now)) .OrderBy(item => item.SortOrder) .ThenBy(item => item.CreatedAt) .Take(limit) .ToArrayAsync(cancellationToken); var taskIds = tasks.Select(item => item.Id).ToArray(); var claimCounts = await dbContext.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PointActivityClaimStatus.Claimed && taskIds.Contains(item.TaskId)) .GroupBy(item => item.TaskId) .Select(group => new { TaskId = group.Key, Count = group.Count() }) .ToDictionaryAsync(item => item.TaskId, item => item.Count, cancellationToken); return new PointList( tasks.Select(task => { var claimedCount = claimCounts.GetValueOrDefault(task.Id); return ToTaskItem(task, claimedCount); }).ToArray()); } public async Task ClaimTaskAsync( PointActor actor, ClaimPointTaskCommand command, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var taskKey = NormalizeKey(command.TaskKey, "task_key_required"); var now = DateTimeOffset.UtcNow; var task = await dbContext.PointActivityTasks .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.TaskKey == taskKey, cancellationToken) ?? throw new PointException("Point activity task was not found.", "point_task_not_found"); if (task.Status != PointActivityTaskStatus.Active || task.StartsAt is not null && task.StartsAt > now || task.EndsAt is not null && task.EndsAt <= now) { throw new PointException("Point activity task is not claimable.", "point_task_inactive"); } if (!string.IsNullOrWhiteSpace(command.SourceType) && command.SourceId.HasValue) { var sourceType = NormalizeOptional(command.SourceType); var existing = await dbContext.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.TaskId == task.Id && item.SourceType == sourceType && item.SourceId == command.SourceId.Value && item.Status == PointActivityClaimStatus.Claimed) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (existing is not null) { return ToClaimItem(existing); } } var claimedCount = await dbContext.PointActivityClaims.CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.TaskId == task.Id && item.Status == PointActivityClaimStatus.Claimed, cancellationToken); if (claimedCount >= task.MaxClaimsPerUser) { throw new PointException("Point activity task claim limit has been reached.", "point_task_claim_limit_reached"); } var claim = new PointActivityClaim { TenantId = actor.TenantId, TaskId = task.Id, UserId = actor.UserId, TaskKey = task.TaskKey, Points = task.Points, Status = PointActivityClaimStatus.Claimed, SourceType = NormalizeOptional(command.SourceType), SourceId = command.SourceId, ClaimedAt = now, Metadata = JsonSerializer.SerializeToElement(new { source = "student_points" }) }; dbContext.PointActivityClaims.Add(claim); 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> GetExchangeItemsAsync( PointActor actor, PointLimitQuery query, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; var items = dbContext.PointExchangeItems .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Status == PointExchangeItemStatus.Active && (item.StartsAt == null || item.StartsAt <= now) && (item.EndsAt == null || item.EndsAt > now)); if (query.RegionId.HasValue) { items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); } var result = await items .OrderBy(item => item.SortOrder) .ThenBy(item => item.PointsCost) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new PointList(result.Select(item => ToExchangeItem(item, balance)).ToArray()); } public async Task CreateExchangeOrderAsync( PointActor actor, CreatePointExchangeOrderCommand command, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); await using var transaction = dbContext.Database.IsRelational() ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var now = DateTimeOffset.UtcNow; var item = await dbContext.PointExchangeItems .SingleOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.ItemId, cancellationToken) ?? throw new PointException("Point exchange item was not found.", "point_exchange_item_not_found"); if (item.Status != PointExchangeItemStatus.Active || item.StartsAt is not null && item.StartsAt > now || item.EndsAt is not null && item.EndsAt <= now) { throw new PointException("Point exchange item is not available.", "point_exchange_item_inactive"); } if (item.Stock is <= 0) { throw new PointException("Point exchange item is sold out.", "point_exchange_item_sold_out"); } var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; if (balance < item.PointsCost) { throw new PointException("Point balance is insufficient.", "insufficient_points"); } if (item.Stock.HasValue) { item.Stock -= 1; } var order = new PointExchangeOrder { TenantId = actor.TenantId, ItemId = item.Id, UserId = actor.UserId, OrderNo = GenerateOrderNo(), ItemName = item.Name, ItemType = item.ItemType, Status = PointExchangeOrderStatus.Completed, PointsCost = item.PointsCost, OrderedAt = now, CompletedAt = now, FulfillmentSnapshot = item.FulfillmentPayload, Metadata = JsonSerializer.SerializeToElement(new { source = "student_points_exchange" }) }; dbContext.PointExchangeOrders.Add(order); 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> GetExchangeOrdersAsync( PointActor actor, PointLimitQuery query, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var orders = dbContext.PointExchangeOrders .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) { orders = orders.Where(item => item.Status == ParseExchangeOrderStatus(query.Status)); } var result = await orders .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new PointList(result.Select(ToExchangeOrderItem).ToArray()); } private async Task GetSummaryCoreAsync( PointActor actor, CancellationToken cancellationToken) { var earned = await dbContext.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PointActivityClaimStatus.Claimed) .SumAsync(item => (int?)item.Points, cancellationToken) ?? 0; var spent = await dbContext.PointExchangeOrders .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PointExchangeOrderStatus.Completed) .SumAsync(item => (int?)item.PointsCost, cancellationToken) ?? 0; return new PointSummaryItem(earned, spent, earned - spent); } private async Task GrantEntitlementAsync( PointActor actor, PointExchangeItem item, PointExchangeOrder order, DateTimeOffset now, CancellationToken cancellationToken) { var days = item.Days ?? ReadInt(item.FulfillmentPayload, "days") ?? 0; var current = await dbContext.Entitlements .Where(entry => entry.TenantId == actor.TenantId && entry.UserId == actor.UserId && entry.EntitlementType == "svip" && entry.Status == EntitlementStatus.Active) .OrderByDescending(entry => entry.ExpiresAt) .FirstOrDefaultAsync(cancellationToken); if (current is null) { dbContext.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = actor.UserId, EntitlementType = "svip", ScopeType = EntitlementScopeType.Tenant, SourceType = "point_exchange_order", SourceId = order.Id, StartsAt = now, ExpiresAt = days > 0 ? now.AddDays(days) : null, Status = EntitlementStatus.Active, Metadata = item.FulfillmentPayload }); } else if (days > 0) { var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > now ? current.ExpiresAt.Value : now; current.ExpiresAt = baseAt.AddDays(days); current.Metadata = item.FulfillmentPayload; } } private async Task AssertActiveMemberAsync(PointActor actor, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active, cancellationToken); if (!exists) { throw new PointException("Current user is not a member of the tenant.", "tenant_access_denied"); } } private static PointTaskItem ToTaskItem(PointActivityTask task, int claimedCount) { return new PointTaskItem( task.Id, task.TaskKey, task.Title, task.Description, task.TaskType.ToString(), task.Status.ToString(), task.Points, task.MaxClaimsPerUser, claimedCount, claimedCount < task.MaxClaimsPerUser, task.StartsAt, task.EndsAt, task.SortOrder, task.Rules, task.Metadata); } private static PointClaimItem ToClaimItem(PointActivityClaim claim) { return new PointClaimItem( claim.Id, claim.TaskId, claim.TaskKey, claim.Points, claim.Status.ToString(), claim.SourceType, claim.SourceId, claim.ClaimedAt, claim.Metadata); } private static PointExchangeItemDto ToExchangeItem(PointExchangeItem item, int balance) { return new PointExchangeItemDto( item.Id, item.RegionId, item.ItemKey, item.Name, item.Description, item.ItemType.ToString(), item.Status.ToString(), item.PointsCost, item.Stock, item.Days, balance >= item.PointsCost && item.Stock is not <= 0, item.SortOrder, item.FulfillmentPayload, item.Metadata); } private static PointExchangeOrderItem ToExchangeOrderItem(PointExchangeOrder order) { return new PointExchangeOrderItem( order.Id, order.ItemId, order.OrderNo, order.ItemName, order.ItemType.ToString(), order.Status.ToString(), order.PointsCost, order.OrderedAt, order.CompletedAt, order.FulfillmentSnapshot, order.Metadata); } private static PointExchangeOrderStatus ParseExchangeOrderStatus(string? status) { return Enum.TryParse( NormalizeEnum(status), true, out var parsed) ? parsed : throw new PointException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); } private static string NormalizeEnum(string? value) => string.Concat((value ?? string.Empty).Split( ['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); private static string NormalizeKey(string? value, string code) { var normalized = NormalizeOptional(value); return !string.IsNullOrWhiteSpace(normalized) ? normalized : throw new PointException("Required point key is missing.", code); } private static string? NormalizeOptional(string? value) { var trimmed = value?.Trim(); return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } private static int? ReadInt(JsonElement payload, string propertyName) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(propertyName, out var property)) { return null; } return property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value) ? value : null; } private static string GenerateOrderNo() { Span bytes = stackalloc byte[6]; RandomNumberGenerator.Fill(bytes); return string.Create( CultureInfo.InvariantCulture, $"PX{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"); } }