forked from gongxuegit/tiku-backend.net
feat: add student video and profile experience
This commit is contained in:
@@ -470,6 +470,7 @@ public sealed class DirectImportDto
|
||||
public Guid? CategoryId { get; set; }
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public bool? Async { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Items { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Units { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Words { get; set; }
|
||||
|
||||
@@ -22,6 +22,13 @@ public sealed class ProfileQueryDto
|
||||
public string? Category { get; set; }
|
||||
|
||||
public bool IncludeLocked { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
public DateTimeOffset? From { get; set; }
|
||||
|
||||
public DateTimeOffset? To { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateProfileDto
|
||||
|
||||
74
Tiku.Api/Contracts/VideoDtos.cs
Normal file
74
Tiku.Api/Contracts/VideoDtos.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Assets;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class VideoSearchQueryDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public VideoSearchQuery ToQuery() => new(Keyword, SubjectId, Limit);
|
||||
}
|
||||
|
||||
public sealed class VideoPlayDto
|
||||
{
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
public VideoPlayCommand ToCommand() => new(VideoId, QuestionId);
|
||||
}
|
||||
|
||||
public sealed class VideoProgressDto
|
||||
{
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int PositionSeconds { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? DurationSeconds { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? WatchedSeconds { get; set; }
|
||||
|
||||
public bool? IsCompleted { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
public VideoProgressCommand ToCommand()
|
||||
{
|
||||
return new VideoProgressCommand(
|
||||
VideoId,
|
||||
QuestionId,
|
||||
PositionSeconds,
|
||||
DurationSeconds,
|
||||
WatchedSeconds,
|
||||
IsCompleted,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QuestionVideoQueryDto
|
||||
{
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public IReadOnlyCollection<Guid>? QuestionIds { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public QuestionVideoQuery ToQuery() => new(QuestionId, QuestionIds, Limit);
|
||||
}
|
||||
@@ -107,6 +107,27 @@ public sealed class ProfileController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
[EndpointSummary("每日签到")]
|
||||
[ProducesResponseType<CheckInResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CheckInResult>> CheckIn(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await profileService.CheckInAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("score-events")]
|
||||
[EndpointSummary("查询积分流水")]
|
||||
[ProducesResponseType<ProfileScoreEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProfileScoreEventList>> ScoreEvents(
|
||||
[FromQuery] ProfileQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await profileService.GetScoreEventsAsync(
|
||||
ResolveActor(),
|
||||
new ProfileScoreEventQuery(query.Limit, query.SourceType, query.From, query.To),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("feedbacks")]
|
||||
[EndpointSummary("提交意见反馈")]
|
||||
[ProducesResponseType<FeedbackItem>(StatusCodes.Status200OK)]
|
||||
|
||||
48
Tiku.Api/Controllers/QuestionVideosController.cs
Normal file
48
Tiku.Api/Controllers/QuestionVideosController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/questions/videos")]
|
||||
public sealed class QuestionVideosController(
|
||||
IVideoPlaybackService videoPlaybackService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询单道题目的解析视频")]
|
||||
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<QuestionVideoCatalogItem>>> List(
|
||||
[FromQuery] QuestionVideoQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("batch")]
|
||||
[EndpointSummary("批量查询题目解析视频")]
|
||||
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<QuestionVideoCatalogItem>>> Batch(
|
||||
QuestionVideoQueryDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), request.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
private VideoPlaybackActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied");
|
||||
}
|
||||
|
||||
return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
@@ -16,6 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/tenant-content")]
|
||||
public sealed class TenantContentDirectController(
|
||||
IDirectContentService directContentService,
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
@@ -318,14 +320,53 @@ public sealed class TenantContentDirectController(
|
||||
|
||||
[HttpPost("imports/{importType}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("执行同步内容导入")]
|
||||
[EndpointSummary("执行或排队内容导入")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<object>> ExecuteImport(
|
||||
string importType,
|
||||
DirectImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.ExecuteImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: false), cancellationToken));
|
||||
var actor = ResolveActor();
|
||||
var command = request.ToCommand(importType, dryRun: false);
|
||||
if (request.Async == true || command.Items.Count > 100)
|
||||
{
|
||||
var job = await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"content_import",
|
||||
System.Text.Json.JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
createdBy = actor.UserId,
|
||||
importType = command.ImportType,
|
||||
sourceFormat = command.SourceFormat,
|
||||
sourceName = command.SourceName,
|
||||
regionId = command.RegionId,
|
||||
entryId = command.EntryId,
|
||||
contentNodeId = command.ContentNodeId,
|
||||
subjectId = command.SubjectId,
|
||||
categoryId = command.CategoryId,
|
||||
questionBankId = command.QuestionBankId,
|
||||
collectionId = command.CollectionId,
|
||||
items = command.Items
|
||||
})),
|
||||
cancellationToken);
|
||||
return Accepted(job);
|
||||
}
|
||||
|
||||
return Ok(await directContentService.ExecuteImportAsync(actor, command, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/detail")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询内容导入任务详情")]
|
||||
[ProducesResponseType<ContentImportJobDetail>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentImportJobDetail>> GetImportDetail(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetImportJobAsync(ResolveActor(), query.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/issues")]
|
||||
|
||||
58
Tiku.Api/Controllers/VideosController.cs
Normal file
58
Tiku.Api/Controllers/VideosController.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/videos")]
|
||||
public sealed class VideosController(
|
||||
IVideoPlaybackService videoPlaybackService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("search")]
|
||||
[EndpointSummary("搜索通用解析视频")]
|
||||
[ProducesResponseType<CatalogList<VideoExplanationCatalogItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VideoExplanationCatalogItem>>> Search(
|
||||
[FromQuery] VideoSearchQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await videoPlaybackService.SearchAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("play")]
|
||||
[EndpointSummary("申请视频播放信息")]
|
||||
[ProducesResponseType<VideoPlaybackItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VideoPlaybackItem>> Play(
|
||||
VideoPlayDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await videoPlaybackService.PlayAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("progress")]
|
||||
[EndpointSummary("上报视频播放进度")]
|
||||
[ProducesResponseType<VideoProgressItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VideoProgressItem>> Progress(
|
||||
VideoProgressDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await videoPlaybackService.ReportProgressAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private VideoPlaybackActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied");
|
||||
}
|
||||
|
||||
return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is VideoPlaybackException videoPlaybackException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
videoPlaybackException.Message,
|
||||
VideoPlaybackStatusCode(videoPlaybackException.Code),
|
||||
videoPlaybackException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ContentManagementException contentManagementException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -436,12 +446,22 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return code switch
|
||||
{
|
||||
"profile_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"profile_user_not_found" => StatusCodes.Status404NotFound,
|
||||
"profile_user_not_found" or "check_in_task_not_found" => StatusCodes.Status404NotFound,
|
||||
"region_not_found" or "school_not_found" or "major_not_found" => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int VideoPlaybackStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"video_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"video_not_found" or "question_video_not_found" => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int TenantAdminDirectStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
|
||||
Reference in New Issue
Block a user