Files
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

458 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(IPointsPersistence pointsPersistence,
ILearningPersistence learningPersistence,
ICommercePersistence commercePersistence,
IIdentityPersistence identityPersistence) : 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 pointsPersistence.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 pointsPersistence.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 pointsPersistence.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 pointsPersistence.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 pointsPersistence.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" })
};
pointsPersistence.PointActivityClaims.Add(claim);
var balanceAfter = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints + task.Points;
learningPersistence.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 pointsPersistence.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 = pointsPersistence.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 = pointsPersistence.Database.IsRelational()
? await pointsPersistence.Database.BeginTransactionAsync(cancellationToken)
: null;
var now = DateTimeOffset.UtcNow;
var item = await pointsPersistence.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" })
};
pointsPersistence.PointExchangeOrders.Add(order);
learningPersistence.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 pointsPersistence.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 = pointsPersistence.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 pointsPersistence.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 pointsPersistence.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 commercePersistence.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)
{
commercePersistence.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 identityPersistence.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)}");
}
}