feat: migrate direct tenant content and learning endpoints

This commit is contained in:
xiong
2026-07-26 17:57:03 +08:00
parent 1d7e09b1f0
commit 91a4162908
18 changed files with 4079 additions and 0 deletions

View File

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

View 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; }
}

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View File

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

View 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);
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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();
}
}

View File

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