feat: add student video and profile experience
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Domain.Catalog;
|
||||
@@ -292,6 +293,133 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
return ToFeedbackItem(feedback);
|
||||
}
|
||||
|
||||
public async Task<CheckInResult> CheckInAsync(
|
||||
ProfileActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var sourceId = CreateDeterministicGuid($"{actor.TenantId:N}:{actor.UserId:N}:check-in:{today:yyyyMMdd}");
|
||||
var idempotencyKey = $"check-in:{actor.TenantId:N}:{actor.UserId:N}:{today:yyyyMMdd}";
|
||||
var existingEvent = await dbContext.UserScoreEvents.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existingEvent is not null)
|
||||
{
|
||||
var existingClaim = await dbContext.PointActivityClaims.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.SourceType == "daily_check_in" &&
|
||||
item.SourceId == sourceId,
|
||||
cancellationToken);
|
||||
return new CheckInResult(
|
||||
today,
|
||||
true,
|
||||
existingEvent.Points,
|
||||
existingEvent.BalanceAfter,
|
||||
existingClaim?.Id,
|
||||
existingEvent.Id);
|
||||
}
|
||||
|
||||
var task = await dbContext.PointActivityTasks
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status == PointActivityTaskStatus.Active &&
|
||||
(item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login"))
|
||||
.OrderByDescending(item => item.TaskKey == "daily_check_in")
|
||||
.ThenBy(item => item.SortOrder)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new ProfileException("Daily check-in point task was not configured.", "check_in_task_not_found");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var claim = new PointActivityClaim
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
TaskId = task.Id,
|
||||
TaskKey = task.TaskKey,
|
||||
UserId = actor.UserId,
|
||||
Points = task.Points,
|
||||
Status = PointActivityClaimStatus.Claimed,
|
||||
SourceType = "daily_check_in",
|
||||
SourceId = sourceId,
|
||||
ClaimedAt = now,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
checkInDate = today,
|
||||
source = "profile_check_in"
|
||||
})
|
||||
};
|
||||
dbContext.PointActivityClaims.Add(claim);
|
||||
var balanceAfter = await CalculatePointBalanceAsync(actor, cancellationToken) + task.Points;
|
||||
var scoreEvent = new UserScoreEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
EventType = UserScoreEventType.CheckIn,
|
||||
Points = task.Points,
|
||||
BalanceAfter = balanceAfter,
|
||||
SourceType = "daily_check_in",
|
||||
SourceId = sourceId,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskKey,
|
||||
checkInDate = today
|
||||
}),
|
||||
CreatedAt = now
|
||||
};
|
||||
dbContext.UserScoreEvents.Add(scoreEvent);
|
||||
profile.LastCheckInDate = today;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new CheckInResult(today, false, task.Points, balanceAfter, claim.Id, scoreEvent.Id);
|
||||
}
|
||||
|
||||
public async Task<ProfileScoreEventList> GetScoreEventsAsync(
|
||||
ProfileActor actor,
|
||||
ProfileScoreEventQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureProfileAsync(actor, cancellationToken);
|
||||
var events = dbContext.UserScoreEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.SourceType))
|
||||
{
|
||||
var sourceType = query.SourceType.Trim();
|
||||
events = events.Where(item => item.SourceType == sourceType);
|
||||
}
|
||||
|
||||
if (query.From.HasValue)
|
||||
{
|
||||
events = events.Where(item => item.CreatedAt >= query.From.Value);
|
||||
}
|
||||
|
||||
if (query.To.HasValue)
|
||||
{
|
||||
events = events.Where(item => item.CreatedAt <= query.To.Value);
|
||||
}
|
||||
|
||||
var items = await events
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.Select(item => new ProfileScoreEventItem(
|
||||
item.Id,
|
||||
ToWire(item.EventType),
|
||||
item.Points,
|
||||
item.BalanceAfter,
|
||||
item.SourceType,
|
||||
item.SourceId,
|
||||
item.Metadata,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new ProfileScoreEventList(items);
|
||||
}
|
||||
|
||||
private async Task<StudentProfile> EnsureProfileAsync(
|
||||
ProfileActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -525,6 +653,31 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
? "_" + char.ToLowerInvariant(ch)
|
||||
: char.ToLowerInvariant(ch).ToString()));
|
||||
}
|
||||
|
||||
private async Task<int> CalculatePointBalanceAsync(ProfileActor 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 earned - spent;
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value));
|
||||
Span<byte> guidBytes = stackalloc byte[16];
|
||||
bytes.AsSpan(0, 16).CopyTo(guidBytes);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||
|
||||
Reference in New Issue
Block a user