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

@@ -0,0 +1,274 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlaybackService
{
public async Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
VideoPlaybackActor actor,
VideoSearchQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var videos = dbContext.VideoExplanations.AsNoTracking()
.Where(video => video.TenantId == actor.TenantId && video.IsActive);
if (query.SubjectId.HasValue)
{
videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null);
}
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
videos = videos.Where(video =>
video.Title.Contains(keyword) ||
(video.Description != null && video.Description.Contains(keyword)));
}
var items = await videos
.OrderBy(video => video.SortOrder)
.ThenBy(video => video.Title)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.Select(video => ToVideoItem(video))
.ToArrayAsync(cancellationToken);
return new CatalogList<VideoExplanationCatalogItem>(items);
}
public async Task<VideoPlaybackItem> PlayAsync(
VideoPlaybackActor actor,
VideoPlayCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
if (command.QuestionId.HasValue)
{
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
}
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
progress.PlayCount++;
progress.LastPlayedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
ActorRole = AssetAccessActorRole.Student,
AccessType = AssetAccessType.Preview,
AssetType = ContentAssetType.Video.ToString(),
Result = AssetAccessResult.Granted,
Metadata = JsonSerializer.SerializeToElement(new
{
videoId = video.Id,
command.QuestionId,
source = "student_video_play"
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return new VideoPlaybackItem(
video.Id,
command.QuestionId,
video.Title,
video.Description,
video.VideoUrl,
video.ThumbnailUrl,
video.DurationSeconds,
ToProgressItem(progress),
video.Metadata);
}
public async Task<VideoProgressItem> ReportProgressAsync(
VideoPlaybackActor actor,
VideoProgressCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
if (command.QuestionId.HasValue)
{
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
}
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
var positionSeconds = Math.Max(command.PositionSeconds, 0);
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
var completed = command.IsCompleted == true ||
durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3);
progress.PositionSeconds = positionSeconds;
progress.DurationSeconds = durationSeconds;
progress.WatchedSeconds = watchedSeconds;
progress.IsCompleted = completed;
progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt;
progress.LastPlayedAt = DateTimeOffset.UtcNow;
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object();
await dbContext.SaveChangesAsync(cancellationToken);
return ToProgressItem(progress);
}
public async Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
VideoPlaybackActor actor,
QuestionVideoQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var questionVideos = dbContext.QuestionVideos.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (query.QuestionId.HasValue)
{
questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
}
if (query.QuestionIds is { Count: > 0 })
{
questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
}
var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive);
var items = await questionVideos
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
.GroupJoin(
videos,
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
video => new { video.TenantId, Id = (Guid?)video.Id },
(questionVideo, matchedVideos) => new
{
QuestionVideo = questionVideo,
Video = matchedVideos.FirstOrDefault()
})
.Select(row => new QuestionVideoCatalogItem(
row.QuestionVideo.Id,
row.QuestionVideo.LegacyId,
row.QuestionVideo.QuestionId,
row.QuestionVideo.VideoId,
row.QuestionVideo.VideoType,
row.QuestionVideo.SortOrder,
row.QuestionVideo.Metadata,
row.Video == null ? null : ToVideoItem(row.Video)))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionVideoCatalogItem>(items);
}
private async Task<VideoPlaybackProgress> ResolveProgressAsync(
VideoPlaybackActor actor,
Guid videoId,
Guid? questionId,
CancellationToken cancellationToken)
{
var progress = await dbContext.VideoPlaybackProgress.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.VideoId == videoId &&
item.QuestionId == questionId,
cancellationToken);
if (progress is not null)
{
return progress;
}
progress = new VideoPlaybackProgress
{
TenantId = actor.TenantId,
UserId = actor.UserId,
VideoId = videoId,
QuestionId = questionId,
LastPlayedAt = DateTimeOffset.UtcNow,
Metadata = JsonDefaults.Object()
};
dbContext.VideoPlaybackProgress.Add(progress);
return progress;
}
private async Task<VideoExplanation> ResolveVideoAsync(
Guid tenantId,
Guid videoId,
CancellationToken cancellationToken)
{
return await dbContext.VideoExplanations.SingleOrDefaultAsync(
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
cancellationToken)
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
}
private async Task AssertQuestionVideoAsync(
Guid tenantId,
Guid questionId,
Guid videoId,
CancellationToken cancellationToken)
{
var exists = await dbContext.QuestionVideos.AnyAsync(
item =>
item.TenantId == tenantId &&
item.QuestionId == questionId &&
item.VideoId == videoId,
cancellationToken);
if (!exists)
{
throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
}
}
private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken)
{
var exists = await dbContext.TenantMemberships.AnyAsync(
membership =>
membership.TenantId == actor.TenantId &&
membership.UserId == actor.UserId &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!exists)
{
throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied");
}
}
private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video)
{
return new VideoExplanationCatalogItem(
video.Id,
video.LegacyId,
video.SubjectId,
video.Title,
video.Description,
video.VideoUrl,
video.ThumbnailUrl,
video.DurationSeconds,
video.KnowledgeTags,
video.IsGeneral,
video.Difficulty,
video.SortOrder,
video.IsActive,
video.Metadata);
}
private static VideoProgressItem ToProgressItem(VideoPlaybackProgress progress)
{
return new VideoProgressItem(
progress.Id,
progress.VideoId,
progress.QuestionId,
progress.PositionSeconds,
progress.DurationSeconds,
progress.WatchedSeconds,
progress.IsCompleted,
progress.CompletedAt,
progress.LastPlayedAt,
progress.PlayCount,
progress.Metadata);
}
}

View File

@@ -1025,6 +1025,46 @@ public sealed class DirectContentService(
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
}
public async Task<ContentImportJobDetail> GetImportJobAsync(
DirectContentActor actor,
Guid jobId,
CancellationToken cancellationToken = default)
{
var job = await dbContext.ContentImportJobs.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
.Select(item => ToJobItem(item))
.SingleOrDefaultAsync(cancellationToken);
if (job is null)
{
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
}
var items = await dbContext.ContentImportItems.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
.OrderBy(item => item.RowNo)
.Take(MaxLimit)
.Select(item => ToImportItem(item))
.ToArrayAsync(cancellationToken);
var issues = await dbContext.ContentImportIssues.AsNoTracking()
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
.OrderBy(issue => issue.RowNo)
.ThenBy(issue => issue.CreatedAt)
.Take(MaxLimit)
.Select(issue => new ContentImportIssueModel(
issue.Id,
issue.JobId,
issue.ItemId,
issue.RowNo,
issue.Severity,
issue.Code,
issue.FieldPath,
issue.Message,
issue.Details))
.ToArrayAsync(cancellationToken);
return new ContentImportJobDetail(job, items, issues);
}
public async Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(
DirectContentActor actor,
Guid jobId,

View File

@@ -112,6 +112,7 @@ public static class DependencyInjection
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddScoped<IAssetAccessService, AssetAccessService>();
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<IVideoPlaybackService, VideoPlaybackService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Content;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
@@ -65,7 +66,7 @@ internal sealed class BackgroundJobService(
var result = await tenantExecutionScope.ExecuteAsync(
job.TenantId,
$"Background job {job.JobType}",
(_, token) => ProcessCoreAsync(job, token),
(provider, token) => ProcessCoreAsync(provider, job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
@@ -114,13 +115,16 @@ internal sealed class BackgroundJobService(
return jobs.Select(ToItem).ToArray();
}
private async Task<JsonElement> ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
private async Task<JsonElement> ProcessCoreAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return job.JobType switch
{
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
"content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."),
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
@@ -129,6 +133,47 @@ internal sealed class BackgroundJobService(
};
}
private static async Task<JsonElement> ProcessContentImportAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
var directContentService = scopedProvider.GetRequiredService<IDirectContentService>();
var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty;
if (createdBy == Guid.Empty)
{
throw new InvalidOperationException("content_import job requires createdBy.");
}
var command = new SimpleImportCommand(
GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."),
GetJsonString(job.Payload, "sourceFormat"),
GetJsonString(job.Payload, "sourceName"),
GetJsonGuid(job.Payload, "regionId"),
GetJsonGuid(job.Payload, "entryId"),
GetJsonGuid(job.Payload, "contentNodeId"),
GetJsonGuid(job.Payload, "subjectId"),
GetJsonGuid(job.Payload, "categoryId"),
GetJsonGuid(job.Payload, "questionBankId"),
GetJsonGuid(job.Payload, "collectionId"),
GetJsonArray(job.Payload, "items"),
false);
var result = await directContentService.ExecuteImportAsync(
new DirectContentActor(job.TenantId, createdBy),
command,
cancellationToken);
return JsonSerializer.SerializeToElement(new
{
importJobId = result.Job.Id,
result.Job.ImportType,
result.Job.Status,
result.Job.TotalCount,
result.Job.InsertedCount,
result.Job.UpdatedCount,
result.Job.ErrorCount
});
}
private async Task<JsonElement> ProcessContentExportAsync(
BackgroundJob job,
CancellationToken cancellationToken)
@@ -300,6 +345,31 @@ internal sealed class BackgroundJobService(
: null;
}
private static Guid? GetJsonGuid(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(propertyName, out var property))
{
return null;
}
return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value)
? value
: null;
}
private static IReadOnlyCollection<JsonElement> GetJsonArray(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(propertyName, out var property) ||
property.ValueKind != JsonValueKind.Array)
{
return [];
}
return property.EnumerateArray().Select(item => item.Clone()).ToArray();
}
private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName)
{
var value = GetJsonString(element, propertyName);

View File

@@ -310,3 +310,41 @@ internal sealed class QuestionVideoConfiguration : IEntityTypeConfiguration<Ques
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfiguration<VideoPlaybackProgress>
{
public void Configure(EntityTypeBuilder<VideoPlaybackProgress> builder)
{
builder.ConfigureTenantEntity("video_playback_progress");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.LastPlayedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId })
.IsUnique()
.HasFilter("question_id is null");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId, entity.QuestionId })
.IsUnique()
.HasFilter("question_id is not null");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastPlayedAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<VideoExplanation>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.VideoId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddVideoPlaybackProgress : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "video_playback_progress",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
video_id = table.Column<Guid>(type: "uuid", nullable: false),
question_id = table.Column<Guid>(type: "uuid", nullable: true),
position_seconds = table.Column<int>(type: "integer", nullable: false),
duration_seconds = table.Column<int>(type: "integer", nullable: true),
watched_seconds = table.Column<int>(type: "integer", nullable: false),
is_completed = table.Column<bool>(type: "boolean", nullable: false),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
last_played_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
play_count = table.Column<int>(type: "integer", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_video_playback_progress", x => x.id);
table.UniqueConstraint("ak_video_playback_progress_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
table.CheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
table.CheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
table.CheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
table.ForeignKey(
name: "fk_video_playback_progress_questions_tenant_id_question_id",
columns: x => new { x.tenant_id, x.question_id },
principalTable: "questions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_video_playback_progress_video_explanations_tenant_id_video_~",
columns: x => new { x.tenant_id, x.video_id },
principalTable: "video_explanations",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_question_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "question_id" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_last_played_at",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "last_played_at" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_video_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "video_id" },
unique: true,
filter: "question_id is null");
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_user_id_video_id_question~",
table: "video_playback_progress",
columns: new[] { "tenant_id", "user_id", "video_id", "question_id" },
unique: true,
filter: "question_id is not null");
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_tenant_id_video_id",
table: "video_playback_progress",
columns: new[] { "tenant_id", "video_id" });
migrationBuilder.CreateIndex(
name: "ix_video_playback_progress_user_id",
table: "video_playback_progress",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "video_playback_progress");
}
}
}

View File

@@ -6579,6 +6579,118 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("video_explanations", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<int?>("DurationSeconds")
.HasColumnType("integer")
.HasColumnName("duration_seconds");
b.Property<bool>("IsCompleted")
.HasColumnType("boolean")
.HasColumnName("is_completed");
b.Property<DateTimeOffset>("LastPlayedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_played_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("PlayCount")
.HasColumnType("integer")
.HasColumnName("play_count");
b.Property<int>("PositionSeconds")
.HasColumnType("integer")
.HasColumnName("position_seconds");
b.Property<Guid?>("QuestionId")
.HasColumnType("uuid")
.HasColumnName("question_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<Guid>("VideoId")
.HasColumnType("uuid")
.HasColumnName("video_id");
b.Property<int>("WatchedSeconds")
.HasColumnType("integer")
.HasColumnName("watched_seconds");
b.HasKey("Id")
.HasName("pk_video_playback_progress");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_video_playback_progress_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_video_playback_progress_user_id");
b.HasIndex("TenantId", "QuestionId")
.HasDatabaseName("ix_video_playback_progress_tenant_id_question_id");
b.HasIndex("TenantId", "VideoId")
.HasDatabaseName("ix_video_playback_progress_tenant_id_video_id");
b.HasIndex("TenantId", "UserId", "LastPlayedAt")
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_last_played_at");
b.HasIndex("TenantId", "UserId", "VideoId")
.IsUnique()
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id")
.HasFilter("question_id is null");
b.HasIndex("TenantId", "UserId", "VideoId", "QuestionId")
.IsUnique()
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id_question~")
.HasFilter("question_id is not null");
b.ToTable("video_playback_progress", null, t =>
{
t.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
t.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
t.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
t.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
});
});
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
{
b.Property<Guid>("Id")
@@ -16197,6 +16309,38 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_video_explanations_subjects_tenant_id_subject_id");
});
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_users_user_id");
b.HasOne("Tiku.Domain.QuestionBanks.Question", null)
.WithMany()
.HasForeignKey("TenantId", "QuestionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_video_playback_progress_questions_tenant_id_question_id");
b.HasOne("Tiku.Domain.Content.VideoExplanation", null)
.WithMany()
.HasForeignKey("TenantId", "VideoId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_video_explanations_tenant_id_video_~");
});
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -98,6 +98,7 @@ public sealed class TikuDbContext(
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
public DbSet<VideoPlaybackProgress> VideoPlaybackProgress => Set<VideoPlaybackProgress>();
public DbSet<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();

View File

@@ -4,6 +4,7 @@ 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;
@@ -125,6 +126,25 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
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);
}
@@ -212,6 +232,23 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
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);

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)