feat: migrate direct tenant content and learning endpoints
This commit is contained in:
@@ -112,6 +112,83 @@ public sealed class AssetUploadConfirmDto
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertAssetDto
|
||||
{
|
||||
public Guid? AssetId { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public Guid? CategoryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public string? AssetKey { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public string? CdnUrl { get; set; }
|
||||
public bool? IsPublic { get; set; }
|
||||
public string? AssetType { get; set; }
|
||||
public string? Visibility { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
public string? Bucket { get; set; }
|
||||
public string? ObjectKey { get; set; }
|
||||
public string? MimeType { get; set; }
|
||||
public long? FileSizeBytes { get; set; }
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
public string? PreviewUrl { get; set; }
|
||||
public string? PreviewObjectKey { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertAssetCommand ToCommand()
|
||||
{
|
||||
return new UpsertAssetCommand(
|
||||
AssetId,
|
||||
RegionId,
|
||||
SubjectId,
|
||||
CategoryId,
|
||||
ContentNodeId,
|
||||
LegacyId,
|
||||
AssetKey,
|
||||
Title,
|
||||
Category,
|
||||
Description,
|
||||
FileName,
|
||||
CdnUrl,
|
||||
IsPublic,
|
||||
AssetType,
|
||||
Visibility,
|
||||
Status,
|
||||
Provider,
|
||||
Bucket,
|
||||
ObjectKey,
|
||||
MimeType,
|
||||
FileSizeBytes,
|
||||
ChecksumSha256,
|
||||
PreviewUrl,
|
||||
PreviewObjectKey,
|
||||
Order,
|
||||
AccessRules,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AssetAccessSignDto
|
||||
{
|
||||
[Required]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
[Range(60, 3600)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
public AssetAccessSignCommand ToCommand()
|
||||
{
|
||||
return new AssetAccessSignCommand(AssetId, ExpiresInSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AssetManagementQueryDto
|
||||
{
|
||||
public Guid? RegionId { get; set; }
|
||||
@@ -156,6 +233,20 @@ public sealed class AssetManagementQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AssetEventQueryDto
|
||||
{
|
||||
public Guid? AssetId { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public AssetEventFilter ToFilter()
|
||||
{
|
||||
return new AssetEventFilter(AssetId, UserId, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImportJobQueryDto
|
||||
{
|
||||
[StringLength(50)]
|
||||
|
||||
449
Tiku.Api/Contracts/DirectContentDtos.cs
Normal file
449
Tiku.Api/Contracts/DirectContentDtos.cs
Normal file
@@ -0,0 +1,449 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class DirectContentQueryDto
|
||||
{
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public Guid? ParentId { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Guid? UnitId { get; set; }
|
||||
public Guid? SchoolId { get; set; }
|
||||
public Guid? MajorId { get; set; }
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
[Range(1900, 3000)]
|
||||
public int? Year { get; set; }
|
||||
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public AdminLimitFilter ToFilter()
|
||||
{
|
||||
return new AdminLimitFilter(
|
||||
RegionId,
|
||||
EntryId,
|
||||
ContentNodeId,
|
||||
ParentId,
|
||||
SubjectId,
|
||||
ChapterId,
|
||||
UnitId,
|
||||
SchoolId,
|
||||
MajorId,
|
||||
QuestionId,
|
||||
Status,
|
||||
Keyword,
|
||||
Year,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectQuestionWriteDto
|
||||
{
|
||||
public Guid? QuestionId { get; set; }
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public Guid? CategoryId { get; set; }
|
||||
public Guid? NodeId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public Guid? PrimaryCollectionId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? TypeLabel { get; set; }
|
||||
|
||||
[Range(1, 5)]
|
||||
public int? Difficulty { get; set; }
|
||||
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
public string? Content { get; set; }
|
||||
public JsonElement Options { get; set; } = JsonDefaults.Array();
|
||||
public int? CorrectOptionIndex { get; set; }
|
||||
public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array();
|
||||
public string? AnswerText { get; set; }
|
||||
public string? Explanation { get; set; }
|
||||
public JsonElement SubQuestions { get; set; } = JsonDefaults.Array();
|
||||
public string? CodeLang { get; set; }
|
||||
public string? CodeTemplate { get; set; }
|
||||
public string? MediaUrl { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public JsonElement ExamMarkers { get; set; } = JsonDefaults.Object();
|
||||
public string? SourceHash { get; set; }
|
||||
public bool? CreateVersion { get; set; }
|
||||
|
||||
public QuestionWriteCommand ToCommand(bool createVersionDefault)
|
||||
{
|
||||
return new QuestionWriteCommand(
|
||||
QuestionId,
|
||||
QuestionBankId,
|
||||
SubjectId,
|
||||
CategoryId,
|
||||
NodeId,
|
||||
EntryId,
|
||||
ContentNodeId,
|
||||
PrimaryCollectionId,
|
||||
LegacyId,
|
||||
Type,
|
||||
TypeLabel,
|
||||
Difficulty,
|
||||
Tags,
|
||||
Content,
|
||||
Options,
|
||||
CorrectOptionIndex,
|
||||
CorrectOptionIndices,
|
||||
AnswerText,
|
||||
Explanation,
|
||||
SubQuestions,
|
||||
CodeLang,
|
||||
CodeTemplate,
|
||||
MediaUrl,
|
||||
Status,
|
||||
ExamMarkers,
|
||||
SourceHash,
|
||||
CreateVersion ?? createVersionDefault);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectVocabularyUnitDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? WordCount { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VocabularyUnitCommand ToCommand()
|
||||
{
|
||||
return new VocabularyUnitCommand(Id, RegionId, EntryId, ContentNodeId, LegacyId, Name, Description, WordCount, Order, IsActive, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectVocabularyWordDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? UnitId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Word { get; set; }
|
||||
public string? Phonetic { get; set; }
|
||||
public string? Meaning { get; set; }
|
||||
public string? Example { get; set; }
|
||||
public string? ExampleTranslation { get; set; }
|
||||
public int? Difficulty { get; set; }
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VocabularyWordCommand ToCommand()
|
||||
{
|
||||
return new VocabularyWordCommand(
|
||||
Id,
|
||||
UnitId,
|
||||
EntryId,
|
||||
ContentNodeId,
|
||||
LegacyId,
|
||||
Word,
|
||||
Phonetic,
|
||||
Meaning,
|
||||
Example,
|
||||
ExampleTranslation,
|
||||
Difficulty,
|
||||
Tags,
|
||||
Order,
|
||||
IsActive,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectHandbookSubjectDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? SchoolId { get; set; }
|
||||
public Guid? MajorId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookSubjectCommand ToCommand()
|
||||
{
|
||||
return new HandbookSubjectCommand(
|
||||
Id,
|
||||
RegionId,
|
||||
SchoolId,
|
||||
MajorId,
|
||||
EntryId,
|
||||
ContentNodeId,
|
||||
LegacyId,
|
||||
Name,
|
||||
Type,
|
||||
Icon,
|
||||
Color,
|
||||
Description,
|
||||
Order,
|
||||
IsActive,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectHandbookChapterDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookChapterCommand ToCommand()
|
||||
{
|
||||
return new HandbookChapterCommand(Id, SubjectId, EntryId, ContentNodeId, LegacyId, Name, Description, Order, IsActive, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectHandbookEntryDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookEntryCommand ToCommand()
|
||||
{
|
||||
return new HandbookEntryCommand(Id, ChapterId, EntryId, ContentNodeId, LegacyId, Title, Summary, Content, Tags, Order, IsActive, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectSchoolDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? ProfessionalExamDate { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public SchoolCommand ToCommand()
|
||||
{
|
||||
return new SchoolCommand(Id, RegionId, LegacyId, Name, ProfessionalExamDate, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectMajorDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? SchoolId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? StudyTips { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
public MajorCommand ToCommand()
|
||||
{
|
||||
return new MajorCommand(Id, RegionId, SchoolId, LegacyId, Name, Description, StudyTips, Order, IsActive);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectVideoDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? VideoUrl { get; set; }
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public JsonElement KnowledgeTags { get; set; } = JsonDefaults.Array();
|
||||
public bool? IsGeneral { get; set; }
|
||||
public int? Difficulty { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VideoExplanationCommand ToCommand()
|
||||
{
|
||||
return new VideoExplanationCommand(
|
||||
Id,
|
||||
SubjectId,
|
||||
LegacyId,
|
||||
Title,
|
||||
Description,
|
||||
VideoUrl,
|
||||
ThumbnailUrl,
|
||||
DurationSeconds,
|
||||
KnowledgeTags,
|
||||
IsGeneral,
|
||||
Difficulty,
|
||||
Order,
|
||||
IsActive,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectQuestionVideoDto
|
||||
{
|
||||
public Guid QuestionId { get; set; }
|
||||
public Guid VideoId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public string? VideoType { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public QuestionVideoCommand ToCommand()
|
||||
{
|
||||
return new QuestionVideoCommand(QuestionId, VideoId, LegacyId, VideoType, Order, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectOperationContentDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? SchoolId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Subtitle { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public string? Question { get; set; }
|
||||
public string? Answer { get; set; }
|
||||
public string? Link { get; set; }
|
||||
public string? ButtonText { get; set; }
|
||||
public string? ButtonLink { get; set; }
|
||||
public string? BackgroundColor { get; set; }
|
||||
public string? BorderColor { get; set; }
|
||||
public string? ExamName { get; set; }
|
||||
public DateTimeOffset? ExamAt { get; set; }
|
||||
public string? ExamType { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public OperationContentCommand ToCommand()
|
||||
{
|
||||
return new OperationContentCommand(
|
||||
Id,
|
||||
RegionId,
|
||||
SchoolId,
|
||||
LegacyId,
|
||||
Title,
|
||||
Subtitle,
|
||||
Content,
|
||||
Question,
|
||||
Answer,
|
||||
Link,
|
||||
ButtonText,
|
||||
ButtonLink,
|
||||
BackgroundColor,
|
||||
BorderColor,
|
||||
ExamName,
|
||||
ExamAt,
|
||||
ExamType,
|
||||
Description,
|
||||
Order,
|
||||
IsActive,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectImportDto
|
||||
{
|
||||
public string? SourceFormat { get; set; }
|
||||
public string? SourceName { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? EntryId { get; set; }
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
public Guid? SubjectId { get; set; }
|
||||
public Guid? CategoryId { get; set; }
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Items { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Units { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Words { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Subjects { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Entries { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Fields { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Schools { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Majors { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Records { get; set; }
|
||||
public IReadOnlyCollection<JsonElement>? Videos { get; set; }
|
||||
|
||||
public SimpleImportCommand ToCommand(string importType, bool dryRun)
|
||||
{
|
||||
return new SimpleImportCommand(
|
||||
importType,
|
||||
SourceFormat,
|
||||
SourceName,
|
||||
RegionId,
|
||||
EntryId,
|
||||
ContentNodeId,
|
||||
SubjectId,
|
||||
CategoryId,
|
||||
QuestionBankId,
|
||||
CollectionId,
|
||||
ResolveItems(importType),
|
||||
dryRun);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<JsonElement> ResolveItems(string importType)
|
||||
{
|
||||
return importType.ToLowerInvariant() switch
|
||||
{
|
||||
"questions" => Items ?? [],
|
||||
"vocabulary" => Words ?? Units ?? Items ?? [],
|
||||
"handbook" => Entries ?? Subjects ?? Items ?? [],
|
||||
"scoreline" => Records ?? Schools ?? Majors ?? Fields ?? Items ?? [],
|
||||
"videos" => Videos ?? Items ?? [],
|
||||
_ => Items ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DirectImportJobDto
|
||||
{
|
||||
public Guid JobId { get; set; }
|
||||
}
|
||||
@@ -184,3 +184,19 @@ public sealed class FavoriteWordDto
|
||||
return new FavoriteWordCommand(WordId, Favorite, Note);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WordReviewDto
|
||||
{
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Result { get; set; }
|
||||
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
public WordReviewCommand ToCommand()
|
||||
{
|
||||
return new WordReviewCommand(WordId, Result, NextReviewAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,34 @@ public sealed class LearningController(
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("stats")]
|
||||
[EndpointSummary("查询学习统计")]
|
||||
[ProducesResponseType<LearningStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningStatsItem>> GetStats(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetStatsAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
[EndpointSummary("查询学习趋势")]
|
||||
[ProducesResponseType<LearningList<LearningTrendItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<LearningTrendItem>>> GetTrend(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("leaderboard")]
|
||||
[EndpointSummary("查询学习排行榜")]
|
||||
[ProducesResponseType<LearningLeaderboardResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningLeaderboardResult>> GetLeaderboard(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("practice-sessions")]
|
||||
[EndpointSummary("创建练习会话")]
|
||||
[ProducesResponseType<PracticeSessionItem>(StatusCodes.Status200OK)]
|
||||
@@ -153,6 +181,19 @@ public sealed class LearningController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("wrong-questions/review-plan")]
|
||||
[EndpointSummary("生成错题复习计划")]
|
||||
[ProducesResponseType<WrongQuestionReviewPlan>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WrongQuestionReviewPlan>> GetWrongQuestionReviewPlan(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWrongQuestionReviewPlanAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("wrong-questions/resolve")]
|
||||
[EndpointSummary("将错题标记为已解决")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
@@ -180,6 +221,19 @@ public sealed class LearningController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/review-plan")]
|
||||
[EndpointSummary("生成单词复习计划")]
|
||||
[ProducesResponseType<WordReviewPlan>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WordReviewPlan>> GetWordReviewPlan(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordReviewPlanAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/progress")]
|
||||
[EndpointSummary("更新单词学习进度")]
|
||||
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
|
||||
@@ -194,6 +248,33 @@ public sealed class LearningController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/review")]
|
||||
[EndpointSummary("提交单词复习结果")]
|
||||
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<WordProgressItem>> ReviewWord(
|
||||
WordReviewDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ReviewWordAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/stats")]
|
||||
[EndpointSummary("查询单词学习统计")]
|
||||
[ProducesResponseType<WordStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WordStatsItem>> GetWordStats(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordStatsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/favorites")]
|
||||
[EndpointSummary("查询收藏单词")]
|
||||
[ProducesResponseType<LearningList<FavoriteWordItem>>(StatusCodes.Status200OK)]
|
||||
|
||||
@@ -166,6 +166,45 @@ public sealed class TenantContentController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("assets/access-events")]
|
||||
[EndpointSummary("查询资产访问审计事件")]
|
||||
[ProducesResponseType<CatalogList<ContentAssetAccessEventItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentAssetAccessEventItem>>> GetAssetAccessEvents(
|
||||
[FromQuery] AssetEventQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.GetAccessEventsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("assets/security-scan-events")]
|
||||
[EndpointSummary("查询资产安全扫描事件")]
|
||||
[ProducesResponseType<CatalogList<ContentAssetSecurityScanEventItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentAssetSecurityScanEventItem>>> GetAssetSecurityScanEvents(
|
||||
[FromQuery] AssetEventQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.GetSecurityScanEventsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("assets")]
|
||||
[EndpointSummary("新增或更新内容资产")]
|
||||
[ProducesResponseType<ContentManagementResult<ContentAssetManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<ContentAssetManagementItem>>> UpsertAsset(
|
||||
UpsertAssetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.UpsertAssetAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/uploads/sign")]
|
||||
[EndpointSummary("创建资产上传签名")]
|
||||
[ProducesResponseType<AssetUploadSignResult>(StatusCodes.Status200OK)]
|
||||
@@ -196,6 +235,32 @@ public sealed class TenantContentController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/sign-download")]
|
||||
[EndpointSummary("签发管理侧资产下载地址")]
|
||||
[ProducesResponseType<AssetManagementSignedAccessResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AssetManagementSignedAccessResult>> SignAssetDownload(
|
||||
AssetAccessSignDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.SignDownloadAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/sign-preview")]
|
||||
[EndpointSummary("签发管理侧资产预览地址")]
|
||||
[ProducesResponseType<AssetManagementSignedAccessResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AssetManagementSignedAccessResult>> SignAssetPreview(
|
||||
AssetAccessSignDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.SignPreviewAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("import-jobs")]
|
||||
[EndpointSummary("查询内容导入任务")]
|
||||
[ProducesResponseType<CatalogList<ContentImportJobItem>>(StatusCodes.Status200OK)]
|
||||
|
||||
315
Tiku.Api/Controllers/TenantContentDirectController.cs
Normal file
315
Tiku.Api/Controllers/TenantContentDirectController.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-content")]
|
||||
public sealed class TenantContentDirectController(
|
||||
IDirectContentService directContentService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpPost("questions")]
|
||||
[EndpointSummary("创建题目及首个版本")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
|
||||
DirectQuestionWriteDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.CreateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: true), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("questions")]
|
||||
[EndpointSummary("更新题目并可选择创建新版本")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
|
||||
DirectQuestionWriteDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpdateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: false), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-units")]
|
||||
[EndpointSummary("查询管理侧词汇单元")]
|
||||
[ProducesResponseType<CatalogList<VocabularyUnit>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyUnit>>> GetVocabularyUnits(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("vocabulary-units")]
|
||||
[EndpointSummary("新增或更新词汇单元")]
|
||||
[ProducesResponseType<ContentManagementResult<VocabularyUnit>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VocabularyUnit>>> UpsertVocabularyUnit(
|
||||
DirectVocabularyUnitDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[EndpointSummary("查询管理侧词汇")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("vocabulary-words")]
|
||||
[EndpointSummary("新增或更新词汇")]
|
||||
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
|
||||
DirectVocabularyWordDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-subjects")]
|
||||
[EndpointSummary("查询管理侧知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookSubject>>> GetHandbookSubjects(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-subjects")]
|
||||
[EndpointSummary("新增或更新知识手册科目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookSubject>>> UpsertHandbookSubject(
|
||||
DirectHandbookSubjectDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[EndpointSummary("查询管理侧知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-chapters")]
|
||||
[EndpointSummary("新增或更新知识手册章节")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
||||
DirectHandbookChapterDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[EndpointSummary("查询管理侧知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-entries")]
|
||||
[EndpointSummary("新增或更新知识手册条目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
||||
DirectHandbookEntryDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/schools")]
|
||||
[EndpointSummary("查询管理侧分数线院校")]
|
||||
[ProducesResponseType<CatalogList<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<School>>> GetSchools(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetSchoolsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/schools")]
|
||||
[EndpointSummary("新增或更新分数线院校")]
|
||||
[ProducesResponseType<ContentManagementResult<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<School>>> UpsertSchool(
|
||||
DirectSchoolDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertSchoolAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/majors")]
|
||||
[EndpointSummary("查询管理侧分数线专业")]
|
||||
[ProducesResponseType<CatalogList<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<Major>>> GetMajors(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetMajorsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/majors")]
|
||||
[EndpointSummary("新增或更新分数线专业")]
|
||||
[ProducesResponseType<ContentManagementResult<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<Major>>> UpsertMajor(
|
||||
DirectMajorDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertMajorAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/years")]
|
||||
[EndpointSummary("查询分数线年份")]
|
||||
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<int>>> GetScorelineYears(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/trend")]
|
||||
[EndpointSummary("查询分数线趋势摘要")]
|
||||
[ProducesResponseType<CatalogList<ScorelineTrendItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ScorelineTrendItem>>> GetScorelineTrend(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("videos")]
|
||||
[EndpointSummary("查询租户视频解析")]
|
||||
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetVideosAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("videos")]
|
||||
[EndpointSummary("新增或更新视频解析")]
|
||||
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
|
||||
DirectVideoDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("question-videos")]
|
||||
[EndpointSummary("绑定题目与解析视频")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
|
||||
DirectQuestionVideoDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("operations/{kind}")]
|
||||
[EndpointSummary("查询运营内容")]
|
||||
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
||||
string kind,
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("operations/{kind}")]
|
||||
[EndpointSummary("新增或更新运营内容")]
|
||||
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
||||
string kind,
|
||||
DirectOperationContentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/preview/{importType}")]
|
||||
[EndpointSummary("预览内容导入数据")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
||||
string importType,
|
||||
DirectImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: true), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/{importType}")]
|
||||
[EndpointSummary("执行同步内容导入")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
|
||||
string importType,
|
||||
DirectImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.ExecuteImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: false), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/issues")]
|
||||
[EndpointSummary("查询内容导入问题明细")]
|
||||
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetImportIssuesAsync(ResolveActor(), query.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/post-check")]
|
||||
[EndpointSummary("执行内容导入后完整性检查")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
||||
DirectImportJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.RunImportPostCheckAsync(ResolveActor(), request.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/post-check")]
|
||||
[EndpointSummary("查询内容导入后检查状态")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await directContentService.GetImportPostCheckAsync(ResolveActor(), query.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
private DirectContentActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new ContentManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied");
|
||||
}
|
||||
|
||||
return new DirectContentActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,37 @@ public sealed record AssetUploadConfirmCommand(
|
||||
long? FileSizeBytes,
|
||||
string? ChecksumSha256);
|
||||
|
||||
public sealed record UpsertAssetCommand(
|
||||
Guid? AssetId,
|
||||
Guid? RegionId,
|
||||
Guid? SubjectId,
|
||||
Guid? CategoryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string? AssetKey,
|
||||
string? Title,
|
||||
string? Category,
|
||||
string? Description,
|
||||
string? FileName,
|
||||
string? CdnUrl,
|
||||
bool? IsPublic,
|
||||
string? AssetType,
|
||||
string? Visibility,
|
||||
string? Status,
|
||||
string? Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
string? MimeType,
|
||||
long? FileSizeBytes,
|
||||
string? ChecksumSha256,
|
||||
string? PreviewUrl,
|
||||
string? PreviewObjectKey,
|
||||
int? Order,
|
||||
JsonElement AccessRules,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record AssetAccessSignCommand(Guid AssetId, int? ExpiresInSeconds);
|
||||
|
||||
public sealed record AssetManagementFilter(
|
||||
Guid? RegionId = null,
|
||||
Guid? SubjectId = null,
|
||||
@@ -54,6 +85,8 @@ public sealed record ImportJobFilter(
|
||||
string? SourceFormat = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record AssetEventFilter(Guid? AssetId = null, Guid? UserId = null, int? Limit = null);
|
||||
|
||||
public sealed record ContentAssetManagementItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
@@ -96,6 +129,39 @@ public sealed record AssetUploadConfirmResult(
|
||||
ContentAssetManagementItem Item,
|
||||
ObjectStorageMetadata Metadata);
|
||||
|
||||
public sealed record AssetManagementSignedAccessResult(
|
||||
ContentAssetManagementItem Item,
|
||||
ObjectStorageSignedUrl Url);
|
||||
|
||||
public sealed record ContentAssetAccessEventItem(
|
||||
Guid Id,
|
||||
Guid? AssetId,
|
||||
Guid? UserId,
|
||||
AssetAccessActorRole ActorRole,
|
||||
AssetAccessType AccessType,
|
||||
string? Visibility,
|
||||
string? AssetType,
|
||||
string? StorageProvider,
|
||||
AssetAccessDisposition? Disposition,
|
||||
int? ExpiresInSeconds,
|
||||
string? SignatureMode,
|
||||
AssetAccessResult Result,
|
||||
string? DenyCode,
|
||||
string? IpAddress,
|
||||
string? UserAgent,
|
||||
JsonElement Metadata,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ContentAssetSecurityScanEventItem(
|
||||
Guid Id,
|
||||
Guid? AssetId,
|
||||
string Provider,
|
||||
AssetSecurityScanStatus ScanStatus,
|
||||
AssetSecurityRiskLevel RiskLevel,
|
||||
string[] IssueCodes,
|
||||
JsonElement Details,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ContentImportJobItem(
|
||||
Guid Id,
|
||||
Guid? TargetRegionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
|
||||
namespace Tiku.Application.Assets;
|
||||
|
||||
@@ -9,6 +10,11 @@ public interface IAssetManagementService
|
||||
AssetManagementFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AssetUploadSignResult> SignUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
@@ -19,6 +25,26 @@ public interface IAssetManagementService
|
||||
AssetUploadConfirmCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AssetManagementSignedAccessResult> SignPreviewAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
|
||||
AssetManagementActor actor,
|
||||
ImportJobFilter filter,
|
||||
|
||||
320
Tiku.Application/Content/DirectContentModels.cs
Normal file
320
Tiku.Application/Content/DirectContentModels.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
|
||||
namespace Tiku.Application.Content;
|
||||
|
||||
public sealed record DirectContentActor(Guid TenantId, Guid UserId);
|
||||
|
||||
public sealed record AdminLimitFilter(
|
||||
Guid? RegionId = null,
|
||||
Guid? EntryId = null,
|
||||
Guid? ContentNodeId = null,
|
||||
Guid? ParentId = null,
|
||||
Guid? SubjectId = null,
|
||||
Guid? ChapterId = null,
|
||||
Guid? UnitId = null,
|
||||
Guid? SchoolId = null,
|
||||
Guid? MajorId = null,
|
||||
Guid? QuestionId = null,
|
||||
string? Status = null,
|
||||
string? Keyword = null,
|
||||
int? Year = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record QuestionWriteCommand(
|
||||
Guid? QuestionId,
|
||||
Guid? QuestionBankId,
|
||||
Guid? SubjectId,
|
||||
Guid? CategoryId,
|
||||
Guid? NodeId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
Guid? PrimaryCollectionId,
|
||||
string? LegacyId,
|
||||
string? Type,
|
||||
string? TypeLabel,
|
||||
int? Difficulty,
|
||||
JsonElement Tags,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate,
|
||||
string? MediaUrl,
|
||||
string? Status,
|
||||
JsonElement ExamMarkers,
|
||||
string? SourceHash,
|
||||
bool CreateVersion);
|
||||
|
||||
public sealed record QuestionManagementItem(
|
||||
Guid Id,
|
||||
Guid? VersionId,
|
||||
Guid? QuestionBankId,
|
||||
Guid? SubjectId,
|
||||
Guid? CategoryId,
|
||||
Guid? NodeId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
Guid? PrimaryCollectionId,
|
||||
string? LegacyId,
|
||||
string Type,
|
||||
string? TypeLabel,
|
||||
int? Difficulty,
|
||||
JsonElement Tags,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate,
|
||||
string? MediaUrl,
|
||||
bool HasVideoExplanation,
|
||||
QuestionStatus Status);
|
||||
|
||||
public sealed record VocabularyUnitCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string Name,
|
||||
string? Description,
|
||||
int? WordCount,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record VocabularyWordCommand(
|
||||
Guid? Id,
|
||||
Guid? UnitId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string Word,
|
||||
string? Phonetic,
|
||||
string? Meaning,
|
||||
string? Example,
|
||||
string? ExampleTranslation,
|
||||
int? Difficulty,
|
||||
JsonElement Tags,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record HandbookSubjectCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
Guid? SchoolId,
|
||||
Guid? MajorId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string Name,
|
||||
string? Type,
|
||||
string? Icon,
|
||||
string? Color,
|
||||
string? Description,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record HandbookChapterCommand(
|
||||
Guid? Id,
|
||||
Guid? SubjectId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string Name,
|
||||
string? Description,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record HandbookEntryCommand(
|
||||
Guid? Id,
|
||||
Guid? ChapterId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
string? LegacyId,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Content,
|
||||
JsonElement Tags,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record SchoolCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
string? LegacyId,
|
||||
string Name,
|
||||
string? ProfessionalExamDate,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record MajorCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
Guid? SchoolId,
|
||||
string? LegacyId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? StudyTips,
|
||||
int? Order,
|
||||
bool? IsActive);
|
||||
|
||||
public sealed record VideoExplanationCommand(
|
||||
Guid? Id,
|
||||
Guid? SubjectId,
|
||||
string? LegacyId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? VideoUrl,
|
||||
string? ThumbnailUrl,
|
||||
int? DurationSeconds,
|
||||
JsonElement KnowledgeTags,
|
||||
bool? IsGeneral,
|
||||
int? Difficulty,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record QuestionVideoCommand(
|
||||
Guid QuestionId,
|
||||
Guid VideoId,
|
||||
string? LegacyId,
|
||||
string? VideoType,
|
||||
int? Order,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record OperationContentCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
Guid? SchoolId,
|
||||
string? LegacyId,
|
||||
string? Title,
|
||||
string? Subtitle,
|
||||
string? Content,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
string? Link,
|
||||
string? ButtonText,
|
||||
string? ButtonLink,
|
||||
string? BackgroundColor,
|
||||
string? BorderColor,
|
||||
string? ExamName,
|
||||
DateTimeOffset? ExamAt,
|
||||
string? ExamType,
|
||||
string? Description,
|
||||
int? Order,
|
||||
bool? IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record SimpleImportCommand(
|
||||
string ImportType,
|
||||
string? SourceFormat,
|
||||
string? SourceName,
|
||||
Guid? RegionId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
Guid? SubjectId,
|
||||
Guid? CategoryId,
|
||||
Guid? QuestionBankId,
|
||||
Guid? CollectionId,
|
||||
IReadOnlyCollection<JsonElement> Items,
|
||||
bool DryRun);
|
||||
|
||||
public sealed record SimpleImportResult(
|
||||
ContentImportJobItem Job,
|
||||
IReadOnlyCollection<ContentImportItemModel> Items,
|
||||
IReadOnlyCollection<ContentImportIssueModel> Issues);
|
||||
|
||||
public sealed record ImportPostCheckResult(Guid JobId, string Status, JsonElement Counts, IReadOnlyCollection<ContentImportIssueModel> Issues);
|
||||
|
||||
public sealed record VideoManagementItem(
|
||||
Guid Id,
|
||||
Guid? SubjectId,
|
||||
string? LegacyId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? VideoUrl,
|
||||
string? ThumbnailUrl,
|
||||
int? DurationSeconds,
|
||||
JsonElement KnowledgeTags,
|
||||
bool IsGeneral,
|
||||
int? Difficulty,
|
||||
int Order,
|
||||
bool IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record QuestionVideoManagementItem(
|
||||
Guid Id,
|
||||
Guid? QuestionId,
|
||||
Guid? VideoId,
|
||||
string? LegacyId,
|
||||
QuestionVideoType VideoType,
|
||||
int Order,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record OperationContentItem(
|
||||
Guid Id,
|
||||
string Kind,
|
||||
Guid? RegionId,
|
||||
Guid? SchoolId,
|
||||
string? LegacyId,
|
||||
string? Title,
|
||||
string? Content,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? ExamAt,
|
||||
string? ExamType,
|
||||
int Order,
|
||||
bool IsActive,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record ScorelineTrendItem(int Year, int SchoolCount, int MajorCount);
|
||||
|
||||
public interface IDirectContentService
|
||||
{
|
||||
Task<ContentManagementResult<QuestionManagementItem>> CreateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<QuestionManagementItem>> UpdateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<VocabularyUnit>> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<VocabularyUnit>> UpsertVocabularyUnitAsync(DirectContentActor actor, VocabularyUnitCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<VocabularyWord>> GetVocabularyWordsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<VocabularyWord>> UpsertVocabularyWordAsync(DirectContentActor actor, VocabularyWordCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<HandbookSubject>> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<HandbookSubject>> UpsertHandbookSubjectAsync(DirectContentActor actor, HandbookSubjectCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<HandbookChapter>> GetHandbookChaptersAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<HandbookChapter>> UpsertHandbookChapterAsync(DirectContentActor actor, HandbookChapterCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<HandbookEntry>> GetHandbookEntriesAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<HandbookEntry>> UpsertHandbookEntryAsync(DirectContentActor actor, HandbookEntryCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<School>> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<School>> UpsertSchoolAsync(DirectContentActor actor, SchoolCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<Major>> GetMajorsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<Major>> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<int>> GetScorelineYearsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<ScorelineTrendItem>> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<VideoManagementItem>> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<VideoManagementItem>> UpsertVideoAsync(DirectContentActor actor, VideoExplanationCommand command, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<QuestionVideoManagementItem>> BindQuestionVideoAsync(DirectContentActor actor, QuestionVideoCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<OperationContentItem>> GetOperationContentAsync(DirectContentActor actor, string kind, AdminLimitFilter filter, CancellationToken cancellationToken = default);
|
||||
Task<ContentManagementResult<OperationContentItem>> UpsertOperationContentAsync(DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SimpleImportResult> PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SimpleImportResult> ExecuteImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default);
|
||||
Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
|
||||
Task<ImportPostCheckResult> RunImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
|
||||
Task<ImportPostCheckResult> GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -2,6 +2,20 @@ namespace Tiku.Application.Learning;
|
||||
|
||||
public interface ILearningActivityService
|
||||
{
|
||||
Task<LearningStatsItem> GetStatsAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<LearningTrendItem>> GetTrendAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningLeaderboardResult> GetLeaderboardAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AnswerRecordItem> SubmitAnswerAsync(
|
||||
LearningActor actor,
|
||||
SubmitAnswerCommand command,
|
||||
@@ -22,6 +36,11 @@ public interface ILearningActivityService
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningActionResult> ResolveWrongQuestionAsync(
|
||||
LearningActor actor,
|
||||
QuestionActionCommand command,
|
||||
@@ -32,11 +51,26 @@ public interface ILearningActivityService
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WordReviewPlan> GetWordReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WordProgressItem> UpdateWordProgressAsync(
|
||||
LearningActor actor,
|
||||
WordProgressCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WordProgressItem> ReviewWordAsync(
|
||||
LearningActor actor,
|
||||
WordReviewCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WordStatsItem> GetWordStatsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
|
||||
@@ -28,6 +28,62 @@ public sealed record FavoriteWordCommand(Guid WordId, bool? Favorite, string? No
|
||||
|
||||
public sealed record LearningLimitFilter(int? Limit = null, string? Status = null, Guid? UnitId = null);
|
||||
|
||||
public sealed record LearningStatsItem(
|
||||
int AnswerCount,
|
||||
int CorrectCount,
|
||||
int WrongCount,
|
||||
int FavoriteQuestionCount,
|
||||
int FavoriteWordCount,
|
||||
int WordProgressCount,
|
||||
int PracticeSessionCount,
|
||||
int PracticeReportCount);
|
||||
|
||||
public sealed record LearningTrendItem(DateOnly Date, int AnswerCount, int CorrectCount, int WrongCount);
|
||||
|
||||
public sealed record LearningLeaderboardItem(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
int AnswerCount,
|
||||
int CorrectCount,
|
||||
int WrongCount,
|
||||
decimal Accuracy);
|
||||
|
||||
public sealed record LearningLeaderboardResult(
|
||||
string Metric,
|
||||
string Period,
|
||||
IReadOnlyCollection<LearningLeaderboardItem> Items,
|
||||
LearningLeaderboardItem? CurrentUser,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record WrongQuestionReviewPlanItem(Guid QuestionId, int WrongCount, DateTimeOffset LastWrongAt);
|
||||
|
||||
public sealed record WrongQuestionReviewPlan(
|
||||
IReadOnlyCollection<WrongQuestionReviewPlanItem> Items,
|
||||
JsonElement NextAction);
|
||||
|
||||
public sealed record WordReviewPlanItem(
|
||||
Guid WordId,
|
||||
WordProgressStatus Status,
|
||||
DateTimeOffset? NextReviewAt,
|
||||
int CorrectCount,
|
||||
int WrongCount,
|
||||
WordDueLevel DueLevel);
|
||||
|
||||
public sealed record WordReviewPlan(
|
||||
IReadOnlyCollection<WordReviewPlanItem> Items,
|
||||
JsonElement NextAction);
|
||||
|
||||
public sealed record WordReviewCommand(Guid WordId, string? Result, DateTimeOffset? NextReviewAt);
|
||||
|
||||
public sealed record WordStatsItem(
|
||||
int Total,
|
||||
int NewCount,
|
||||
int LearningCount,
|
||||
int ReviewingCount,
|
||||
int MasteredCount,
|
||||
int DueCount,
|
||||
int FavoriteCount);
|
||||
|
||||
public sealed record PracticeSessionCommand(
|
||||
string? Mode,
|
||||
string? TargetType,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
@@ -91,6 +92,46 @@ public sealed class AssetManagementService(
|
||||
return new CatalogList<ContentAssetManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.LegacyId = NormalizeOptional(command.LegacyId);
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源";
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.FileName = NormalizeOptional(command.FileName);
|
||||
asset.CdnUrl = NormalizeOptional(command.CdnUrl);
|
||||
asset.IsPublic = command.IsPublic ?? asset.IsPublic;
|
||||
asset.AssetType = ParseEnum(command.AssetType, asset.AssetType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.Status = ParseEnum(command.Status, asset.Status);
|
||||
asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider)));
|
||||
asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim();
|
||||
asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? asset.ObjectKey
|
||||
: objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim());
|
||||
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes);
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
asset.PreviewUrl = NormalizeOptional(command.PreviewUrl);
|
||||
asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey;
|
||||
asset.SortOrder = command.Order ?? asset.SortOrder;
|
||||
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public async Task<AssetUploadSignResult> SignUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
@@ -227,6 +268,92 @@ public sealed class AssetManagementService(
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignPreviewAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetAccessEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetAccessEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.UserId,
|
||||
item.ActorRole,
|
||||
item.AccessType,
|
||||
item.Visibility,
|
||||
item.AssetType,
|
||||
item.StorageProvider,
|
||||
item.Disposition,
|
||||
item.ExpiresInSeconds,
|
||||
item.SignatureMode,
|
||||
item.Result,
|
||||
item.DenyCode,
|
||||
item.IpAddress,
|
||||
item.UserAgent,
|
||||
item.Metadata,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetAccessEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetSecurityScanEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.Provider,
|
||||
item.ScanStatus,
|
||||
item.RiskLevel,
|
||||
item.IssueCodes,
|
||||
item.Details,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
|
||||
AssetManagementActor actor,
|
||||
ImportJobFilter filter,
|
||||
@@ -372,6 +499,107 @@ public sealed class AssetManagementService(
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<ContentAsset> ResolveManagementAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
|
||||
{
|
||||
var legacyId = command.LegacyId.Trim();
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.LegacyId == legacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (asset is not null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
UpdatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
AssetAccessType accessType,
|
||||
string disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var objectKey = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||
: asset.ObjectKey;
|
||||
var cdnUrl = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewUrl ?? asset.CdnUrl
|
||||
: asset.CdnUrl;
|
||||
var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600));
|
||||
var url = await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
objectKey,
|
||||
expiresIn,
|
||||
cdnUrl,
|
||||
asset.FileName,
|
||||
disposition),
|
||||
cancellationToken);
|
||||
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
AssetId = asset.Id,
|
||||
UserId = actor.UserId,
|
||||
ActorRole = AssetAccessActorRole.TenantAdmin,
|
||||
AccessType = accessType,
|
||||
Visibility = asset.Visibility.ToString(),
|
||||
AssetType = asset.AssetType.ToString(),
|
||||
StorageProvider = asset.StorageProvider.ToString(),
|
||||
Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment,
|
||||
ExpiresInSeconds = (int)expiresIn.TotalSeconds,
|
||||
SignatureMode = url.SignatureMode,
|
||||
Result = AssetAccessResult.Granted,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
url.Provider,
|
||||
url.Bucket,
|
||||
url.ObjectKey
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetManagementSignedAccessResult(ToItem(asset), url);
|
||||
}
|
||||
|
||||
private static ContentAssetManagementItem ToItem(ContentAsset asset)
|
||||
{
|
||||
return new ContentAssetManagementItem(
|
||||
@@ -527,6 +755,19 @@ public sealed class AssetManagementService(
|
||||
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType)
|
||||
{
|
||||
return assetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||
|
||||
1740
Tiku.Infrastructure/Content/DirectContentService.cs
Normal file
1740
Tiku.Infrastructure/Content/DirectContentService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IContentManagementService, ContentManagementService>();
|
||||
services.AddScoped<IDirectContentService, DirectContentService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
|
||||
@@ -14,6 +14,100 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
|
||||
public async Task<LearningStatsItem> GetStatsAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var answers = dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
return new LearningStatsItem(
|
||||
await answers.CountAsync(cancellationToken),
|
||||
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
|
||||
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
|
||||
cancellationToken),
|
||||
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
|
||||
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.AnsweredAt >= since)
|
||||
.Select(item => new { item.AnsweredAt, item.IsCorrect })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = rows
|
||||
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => new LearningTrendItem(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Count(item => item.IsCorrect == true),
|
||||
group.Count(item => item.IsCorrect == false)))
|
||||
.ToArray();
|
||||
return new LearningList<LearningTrendItem>(items);
|
||||
}
|
||||
|
||||
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.GroupBy(item => item.UserId)
|
||||
.Select(group => new
|
||||
{
|
||||
UserId = group.Key,
|
||||
AnswerCount = group.Count(),
|
||||
CorrectCount = group.Count(item => item.IsCorrect == true),
|
||||
WrongCount = group.Count(item => item.IsCorrect == false)
|
||||
})
|
||||
.OrderByDescending(item => item.CorrectCount)
|
||||
.ThenByDescending(item => item.AnswerCount)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var userIds = rows.Select(item => item.UserId).ToArray();
|
||||
var names = await dbContext.Users.AsNoTracking()
|
||||
.Where(item => userIds.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
|
||||
var items = rows
|
||||
.Select(item => new LearningLeaderboardItem(
|
||||
item.UserId,
|
||||
names.GetValueOrDefault(item.UserId),
|
||||
item.AnswerCount,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
|
||||
.ToArray();
|
||||
return new LearningLeaderboardResult(
|
||||
"correct_count",
|
||||
"all",
|
||||
items,
|
||||
items.FirstOrDefault(item => item.UserId == actor.UserId),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<AnswerRecordItem> SubmitAnswerAsync(
|
||||
LearningActor actor,
|
||||
SubmitAnswerCommand command,
|
||||
@@ -201,6 +295,31 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
return new LearningActionResult(true);
|
||||
}
|
||||
|
||||
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await dbContext.WrongQuestions.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.ResolvedAt == null)
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WrongQuestionReviewPlan(
|
||||
items,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "wrong_review",
|
||||
questionCount = items.Length,
|
||||
recommendedEndpoint = "/api/learning/practice-sessions"
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
@@ -312,6 +431,91 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
return ToItem(item);
|
||||
}
|
||||
|
||||
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var query = dbContext.UserWordProgress.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
|
||||
.OrderBy(item => item.NextReviewAt == null)
|
||||
.ThenBy(item => item.NextReviewAt)
|
||||
.ThenByDescending(item => item.WrongCount)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WordReviewPlanItem(
|
||||
item.WordId,
|
||||
item.Status,
|
||||
item.NextReviewAt,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.DueLevel))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WordReviewPlan(
|
||||
items,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "word_review",
|
||||
wordCount = items.Length
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<WordProgressItem> ReviewWordAsync(
|
||||
LearningActor actor,
|
||||
WordReviewCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
|
||||
return UpdateWordProgressAsync(
|
||||
actor,
|
||||
new WordProgressCommand(
|
||||
command.WordId,
|
||||
correct ? "Reviewing" : "Learning",
|
||||
correct ? 1 : 0,
|
||||
correct ? 0 : 1,
|
||||
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WordStatsItem> GetWordStatsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var query = dbContext.UserWordProgress.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
return new WordStatsItem(
|
||||
await query.CountAsync(cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
|
||||
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
|
||||
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
|
||||
@@ -198,6 +198,58 @@ public sealed class AssetManagementEndpointTests
|
||||
Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_upsert_asset_sign_access_and_query_asset_events()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var upsertResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/assets",
|
||||
new UpsertAssetDto
|
||||
{
|
||||
Title = "管理侧资料",
|
||||
FileName = "admin.pdf",
|
||||
AssetType = "pdf",
|
||||
Visibility = "members",
|
||||
Provider = "local_dev",
|
||||
Bucket = "tenant-assets",
|
||||
ObjectKey = $"{seed.TenantId:N}/admin.pdf",
|
||||
PreviewObjectKey = $"{seed.TenantId:N}/admin-preview.pdf",
|
||||
MimeType = "application/pdf",
|
||||
FileSizeBytes = 512
|
||||
});
|
||||
var upsert = await ReadJsonAsync(upsertResponse);
|
||||
var assetId = upsert.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
await factory.SeedAsync(new ContentAssetSecurityScanEvent
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
AssetId = assetId,
|
||||
Provider = "local",
|
||||
ScanStatus = AssetSecurityScanStatus.Passed,
|
||||
RiskLevel = AssetSecurityRiskLevel.None
|
||||
});
|
||||
|
||||
var downloadResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/assets/sign-download",
|
||||
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
|
||||
var previewResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/assets/sign-preview",
|
||||
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
|
||||
var accessEventsResponse = await client.GetAsync($"/api/tenant-content/assets/access-events?assetId={assetId}");
|
||||
var scanEventsResponse = await client.GetAsync($"/api/tenant-content/assets/security-scan-events?assetId={assetId}");
|
||||
var accessEvents = await ReadJsonAsync(accessEventsResponse);
|
||||
var scanEvents = await ReadJsonAsync(scanEventsResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, downloadResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
Assert.Equal(2, accessEvents.RootElement.GetProperty("items").GetArrayLength());
|
||||
Assert.Single(scanEvents.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
250
Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs
Normal file
250
Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class DirectContentEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_create_question_and_sync_primary_collection()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var collectionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = seed.TenantId,
|
||||
Name = "直接迁移题集",
|
||||
CollectionType = QuestionCollectionType.Manual,
|
||||
SourceType = QuestionCollectionSourceType.ManualQuestions
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/questions",
|
||||
new DirectQuestionWriteDto
|
||||
{
|
||||
PrimaryCollectionId = collectionId,
|
||||
Type = "choice",
|
||||
Content = "1 + 1 = ?",
|
||||
Options = JsonSerializer.SerializeToElement(new[] { "1", "2" }),
|
||||
CorrectOptionIndex = 1,
|
||||
Tags = JsonSerializer.SerializeToElement(new[] { "math" })
|
||||
});
|
||||
var json = await ReadJsonAsync(response);
|
||||
var questionId = json.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(collectionId, json.RootElement.GetProperty("item").GetProperty("primaryCollectionId").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.QuestionCollectionItems, item => item.CollectionId == collectionId && item.QuestionId == questionId);
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_upsert_vocabulary_handbook_video_and_operations_content()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var unitResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/vocabulary-units",
|
||||
new DirectVocabularyUnitDto { Name = "Unit 1", WordCount = 1 });
|
||||
var unitJson = await ReadJsonAsync(unitResponse);
|
||||
var unitId = unitJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var wordResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/vocabulary-words",
|
||||
new DirectVocabularyWordDto { UnitId = unitId, Word = "scale", Meaning = "规模" });
|
||||
|
||||
var subjectResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/handbook-subjects",
|
||||
new DirectHandbookSubjectDto { Name = "文化常识", Type = "Common" });
|
||||
var subjectJson = await ReadJsonAsync(subjectResponse);
|
||||
var subjectId = subjectJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var chapterResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/handbook-chapters",
|
||||
new DirectHandbookChapterDto { SubjectId = subjectId, Name = "第一章" });
|
||||
var chapterJson = await ReadJsonAsync(chapterResponse);
|
||||
var chapterId = chapterJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var entryResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/handbook-entries",
|
||||
new DirectHandbookEntryDto { ChapterId = chapterId, Title = "知识点", Content = "正文" });
|
||||
|
||||
var videoResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/videos",
|
||||
new DirectVideoDto { Title = "解析视频", VideoUrl = "https://example.test/video.mp4" });
|
||||
|
||||
var bannerResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/operations/banners",
|
||||
new DirectOperationContentDto { Title = "开屏", Content = "欢迎", IsActive = true });
|
||||
using var bannerListResponse = await client.GetAsync("/api/tenant-content/operations/banners");
|
||||
var bannerListJson = await ReadJsonAsync(bannerListResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, unitResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, wordResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, subjectResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, chapterResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, videoResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, bannerResponse.StatusCode);
|
||||
Assert.Single(bannerListJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_bind_question_video_and_run_import_skeleton()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var questionResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/questions",
|
||||
new DirectQuestionWriteDto { Type = "choice", Content = "题目" });
|
||||
var questionJson = await ReadJsonAsync(questionResponse);
|
||||
var questionId = questionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var videoResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-content/videos",
|
||||
new DirectVideoDto { Title = "视频" });
|
||||
var videoJson = await ReadJsonAsync(videoResponse);
|
||||
var videoId = videoJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var bindResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/question-videos",
|
||||
new DirectQuestionVideoDto { QuestionId = questionId, VideoId = videoId });
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/imports/preview/questions",
|
||||
new DirectImportDto
|
||||
{
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题" })
|
||||
]
|
||||
});
|
||||
var previewJson = await ReadJsonAsync(previewResponse);
|
||||
var previewJobId = previewJson.RootElement.GetProperty("job").GetProperty("id").GetGuid();
|
||||
|
||||
var executeResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/imports/questions",
|
||||
new DirectImportDto
|
||||
{
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题" })
|
||||
]
|
||||
});
|
||||
var executeJson = await ReadJsonAsync(executeResponse);
|
||||
var executeJobId = executeJson.RootElement.GetProperty("job").GetProperty("id").GetGuid();
|
||||
|
||||
var postCheckResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/imports/post-check",
|
||||
new DirectImportJobDto { JobId = executeJobId });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, bindResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, postCheckResponse.StatusCode);
|
||||
Assert.NotEqual(Guid.Empty, previewJobId);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(dbContext.ContentImportJobs.Any(item => item.Id == executeJobId && item.InsertedCount == 1));
|
||||
Assert.True(dbContext.Questions.Count(item => item.TenantId == seed.TenantId) >= 2);
|
||||
Assert.True(dbContext.Questions.Single(item => item.Id == questionId).HasVideoExplanation);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"137{Random.Shared.Next(10000000, 99999999)}";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Test Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,78 @@ public sealed class LearningEndpointTests
|
||||
Assert.Equal("重点", item.GetProperty("note").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Learning_stats_plans_leaderboard_and_word_review_are_available()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
var wordId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new VocabularyWord
|
||||
{
|
||||
Id = wordId,
|
||||
TenantId = seed.TenantId,
|
||||
Word = "review",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = questionId,
|
||||
SelectedOptions = ["A"],
|
||||
SelfJudgedCorrect = false
|
||||
});
|
||||
await client.PostAsJsonAsync(
|
||||
"/api/learning/vocabulary/progress",
|
||||
new WordProgressDto
|
||||
{
|
||||
WordId = wordId,
|
||||
Status = "learning",
|
||||
WrongDelta = 1,
|
||||
NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
});
|
||||
|
||||
var statsResponse = await client.GetAsync("/api/learning/stats");
|
||||
var trendResponse = await client.GetAsync("/api/learning/trend?limit=7");
|
||||
var leaderboardResponse = await client.GetAsync("/api/learning/leaderboard?limit=10");
|
||||
var wrongPlanResponse = await client.GetAsync("/api/learning/wrong-questions/review-plan");
|
||||
var wordPlanResponse = await client.GetAsync("/api/learning/vocabulary/review-plan");
|
||||
var reviewResponse = await client.PostAsJsonAsync(
|
||||
"/api/learning/vocabulary/review",
|
||||
new WordReviewDto { WordId = wordId, Result = "correct" });
|
||||
var wordStatsResponse = await client.GetAsync("/api/learning/vocabulary/stats");
|
||||
|
||||
var stats = await ReadJsonAsync(statsResponse);
|
||||
var trend = await ReadItemsAsync(trendResponse);
|
||||
var leaderboard = await ReadJsonAsync(leaderboardResponse);
|
||||
var wrongPlan = await ReadJsonAsync(wrongPlanResponse);
|
||||
var wordPlan = await ReadJsonAsync(wordPlanResponse);
|
||||
var review = await ReadJsonAsync(reviewResponse);
|
||||
var wordStats = await ReadJsonAsync(wordStatsResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, statsResponse.StatusCode);
|
||||
Assert.Equal(1, stats.RootElement.GetProperty("answerCount").GetInt32());
|
||||
Assert.Equal(1, stats.RootElement.GetProperty("wrongCount").GetInt32());
|
||||
Assert.NotEmpty(trend);
|
||||
Assert.Equal(seed.UserId, Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId").GetGuid());
|
||||
Assert.Single(wrongPlan.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Single(wordPlan.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal(1, review.RootElement.GetProperty("correctCount").GetInt32());
|
||||
Assert.Equal(1, wordStats.RootElement.GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
|
||||
ApiTestFactory factory)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user