forked from xiongyuxing/tiku-backend.net
feat: add student video and profile experience
This commit is contained in:
274
Tiku.Infrastructure/Assets/VideoPlaybackService.cs
Normal file
274
Tiku.Infrastructure/Assets/VideoPlaybackService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user