feat: add student video and profile experience

This commit is contained in:
2026-07-28 15:23:42 +08:00
parent 5e993298e7
commit 7a3cf09a99
31 changed files with 19821 additions and 170 deletions

View File

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