diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index afce631..ae734b6 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -23,6 +23,7 @@ internal static class ApiPresentationExtensions }); services.AddProblemDetails(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Api/Controllers/ContentImportController.cs b/Tiku.Api/Controllers/ContentImportController.cs new file mode 100644 index 0000000..800d042 --- /dev/null +++ b/Tiku.Api/Controllers/ContentImportController.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class ContentImportController( + IContentImportService service, + IBackgroundJobQueue backgroundJobService, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpPost("imports/preview/{importType}")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeatureFromRoute("importType")] + [EndpointSummary("预览内容导入数据")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PreviewImport( + string importType, + DirectImportDto request, + CancellationToken cancellationToken) + { + return Ok(await service.PreviewImportAsync(actorResolver.Resolve(), request.ToCommand(importType, true), + cancellationToken)); + } + + [HttpPost("imports/{importType}")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeatureFromRoute("importType")] + [EndpointSummary("执行或排队内容导入")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public async Task> ExecuteImport( + string importType, + DirectImportDto request, + CancellationToken cancellationToken) + { + var actor = actorResolver.Resolve(); + var command = request.ToCommand(importType, false); + if (request.Async == true || command.Items.Count > 100) + { + var job = await backgroundJobService.EnqueueAsync( + new CreateBackgroundJobCommand( + actor.TenantId, + "content_import", + JsonSerializer.SerializeToElement(new + { + createdBy = actor.UserId, + importType = command.ImportType, + sourceFormat = command.SourceFormat, + sourceName = command.SourceName, + regionId = command.RegionId, + entryId = command.EntryId, + contentNodeId = command.ContentNodeId, + subjectId = command.SubjectId, + categoryId = command.CategoryId, + questionBankId = command.QuestionBankId, + collectionId = command.CollectionId, + items = command.Items + })), + cancellationToken); + return Accepted(job); + } + + return Ok(await service.ExecuteImportAsync(actor, command, cancellationToken)); + } + + [HttpGet("imports/detail")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [EndpointSummary("查询内容导入任务详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetImportDetail( + [FromQuery] DirectImportJobDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetImportJobAsync(actorResolver.Resolve(), query.JobId, cancellationToken)); + } + + [HttpGet("imports/issues")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [EndpointSummary("查询内容导入问题明细")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetImportIssues( + [FromQuery] DirectImportJobDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetImportIssuesAsync(actorResolver.Resolve(), query.JobId, cancellationToken)); + } + + [HttpPost("imports/post-check")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [EndpointSummary("执行内容导入后完整性检查")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RunImportPostCheck( + DirectImportJobDto request, + CancellationToken cancellationToken) + { + return Ok(await service.RunImportPostCheckAsync(actorResolver.Resolve(), request.JobId, cancellationToken)); + } + + [HttpGet("imports/post-check")] + [Authorize(Policy = BackendPermissions.TenantJobManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [EndpointSummary("查询内容导入后检查状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetImportPostCheck( + [FromQuery] DirectImportJobDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetImportPostCheckAsync(actorResolver.Resolve(), query.JobId, cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/DirectContentActorResolver.cs b/Tiku.Api/Controllers/DirectContentActorResolver.cs new file mode 100644 index 0000000..9093236 --- /dev/null +++ b/Tiku.Api/Controllers/DirectContentActorResolver.cs @@ -0,0 +1,18 @@ +using Tiku.Application.Content; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +public sealed class DirectContentActorResolver( + ICurrentUser currentUser, + ITenantContext currentTenant) +{ + internal DirectContentActor Resolve() + { + 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); + } +} diff --git a/Tiku.Api/Controllers/EducationCatalogManagementController.cs b/Tiku.Api/Controllers/EducationCatalogManagementController.cs new file mode 100644 index 0000000..ab9c8b4 --- /dev/null +++ b/Tiku.Api/Controllers/EducationCatalogManagementController.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class EducationCatalogManagementController( + IEducationCatalogManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("scoreline/schools")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询管理侧分数线院校")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetSchools( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetSchoolsAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("scoreline/schools")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("新增或更新分数线院校")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertSchool( + DirectSchoolDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertSchoolAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("scoreline/majors")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询管理侧分数线专业")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetMajors( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetMajorsAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("scoreline/majors")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("新增或更新分数线专业")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertMajor( + DirectMajorDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertMajorAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/HandbookManagementController.cs b/Tiku.Api/Controllers/HandbookManagementController.cs new file mode 100644 index 0000000..b17becd --- /dev/null +++ b/Tiku.Api/Controllers/HandbookManagementController.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class HandbookManagementController( + IHandbookManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("handbook-subjects")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("查询管理侧知识手册科目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookSubjects( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetHandbookSubjectsAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("handbook-subjects")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("新增或更新知识手册科目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookSubject( + DirectHandbookSubjectDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertHandbookSubjectAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } + + [HttpGet("handbook-chapters")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("查询管理侧知识手册章节")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookChapters( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetHandbookChaptersAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("handbook-chapters")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("新增或更新知识手册章节")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookChapter( + DirectHandbookChapterDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertHandbookChapterAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } + + [HttpGet("handbook-entries")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("查询管理侧知识手册条目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookEntries( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetHandbookEntriesAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("handbook-entries")] + [Authorize(Policy = BackendPermissions.TenantHandbookManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [EndpointSummary("新增或更新知识手册条目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookEntry( + DirectHandbookEntryDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertHandbookEntryAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/OperationContentManagementController.cs b/Tiku.Api/Controllers/OperationContentManagementController.cs new file mode 100644 index 0000000..a27df28 --- /dev/null +++ b/Tiku.Api/Controllers/OperationContentManagementController.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class OperationContentManagementController( + IOperationContentManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("operations/{kind}")] + [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [EndpointSummary("查询运营内容")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetOperationContent( + string kind, + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetOperationContentAsync(actorResolver.Resolve(), kind, query.ToFilter(), + cancellationToken)); + } + + [HttpPut("operations/{kind}")] + [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [EndpointSummary("新增或更新运营内容")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertOperationContent( + string kind, + DirectOperationContentDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertOperationContentAsync(actorResolver.Resolve(), kind, request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/QuestionManagementController.cs b/Tiku.Api/Controllers/QuestionManagementController.cs new file mode 100644 index 0000000..072ef24 --- /dev/null +++ b/Tiku.Api/Controllers/QuestionManagementController.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class QuestionManagementController( + IQuestionManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpPost("questions")] + [Authorize(Policy = BackendPermissions.TenantContentManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] + [EndpointSummary("创建题目及首个版本")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> CreateQuestion( + DirectQuestionWriteDto request, + CancellationToken cancellationToken) + { + return Ok(await service.CreateQuestionAsync(actorResolver.Resolve(), request.ToCommand(true), + cancellationToken)); + } + + [HttpPatch("questions")] + [Authorize(Policy = BackendPermissions.TenantContentManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] + [EndpointSummary("更新题目并可选择创建新版本")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpdateQuestion( + DirectQuestionWriteDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpdateQuestionAsync(actorResolver.Resolve(), request.ToCommand(false), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/ScorelineManagementController.cs b/Tiku.Api/Controllers/ScorelineManagementController.cs new file mode 100644 index 0000000..e384e79 --- /dev/null +++ b/Tiku.Api/Controllers/ScorelineManagementController.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class ScorelineManagementController( + IScorelineManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("scoreline/fields")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询管理侧分数线字段")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineFields( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetScorelineFieldsAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("scoreline/fields")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("新增或更新动态分数线字段")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertScorelineField( + DirectScorelineFieldDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertScorelineFieldAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } + + [HttpGet("scoreline/records")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询管理侧分数线记录")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineRecords( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetScorelineRecordsAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("scoreline/records")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("新增或更新分数线记录")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertScorelineRecord( + DirectScorelineRecordDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertScorelineRecordAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } + + [HttpGet("scoreline/years")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询分数线年份")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineYears( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok( + await service.GetScorelineYearsAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("scoreline/trend")] + [Authorize(Policy = BackendPermissions.TenantScorelineManage)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [EndpointSummary("查询分数线趋势摘要")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineTrend( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok( + await service.GetScorelineTrendAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/TenantContentDirectController.cs b/Tiku.Api/Controllers/TenantContentDirectController.cs deleted file mode 100644 index 972a7e9..0000000 --- a/Tiku.Api/Controllers/TenantContentDirectController.cs +++ /dev/null @@ -1,499 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Tiku.Api.Contracts; -using Tiku.Api.Security; -using Tiku.Application.Assets; -using Tiku.Application.Catalog; -using Tiku.Application.Content; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Content; - -namespace Tiku.Api.Controllers; - -[ApiController] -[Tags("租户端-内容直接管理")] -[Produces("application/json")] -[Route("api/tenant/content")] -public sealed class TenantContentDirectController( - IDirectContentService directContentService, - IBackgroundJobQueue backgroundJobService, - ICurrentUser currentUser, - ITenantContext currentTenant) : ControllerBase -{ - [HttpPost("questions")] - [Authorize(Policy = BackendPermissions.TenantContentManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] - [EndpointSummary("创建题目及首个版本")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> CreateQuestion( - DirectQuestionWriteDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.CreateQuestionAsync(ResolveActor(), request.ToCommand(true), - cancellationToken)); - } - - [HttpPatch("questions")] - [Authorize(Policy = BackendPermissions.TenantContentManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] - [EndpointSummary("更新题目并可选择创建新版本")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpdateQuestion( - DirectQuestionWriteDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpdateQuestionAsync(ResolveActor(), request.ToCommand(false), - cancellationToken)); - } - - [HttpGet("vocabulary-units")] - [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] - [EndpointSummary("查询管理侧词汇单元")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetVocabularyUnits( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("vocabulary-units")] - [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] - [EndpointSummary("新增或更新词汇单元")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertVocabularyUnit( - DirectVocabularyUnitDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("vocabulary-words")] - [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] - [EndpointSummary("查询管理侧词汇")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetVocabularyWords( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("vocabulary-words")] - [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] - [EndpointSummary("新增或更新词汇")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertVocabularyWord( - DirectVocabularyWordDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("handbook-subjects")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("查询管理侧知识手册科目")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetHandbookSubjects( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("handbook-subjects")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("新增或更新知识手册科目")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertHandbookSubject( - DirectHandbookSubjectDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("handbook-chapters")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("查询管理侧知识手册章节")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetHandbookChapters( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("handbook-chapters")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("新增或更新知识手册章节")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertHandbookChapter( - DirectHandbookChapterDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("handbook-entries")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("查询管理侧知识手册条目")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetHandbookEntries( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("handbook-entries")] - [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Handbook)] - [EndpointSummary("新增或更新知识手册条目")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertHandbookEntry( - DirectHandbookEntryDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("scoreline/schools")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询管理侧分数线院校")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetSchools( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetSchoolsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpPut("scoreline/schools")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("新增或更新分数线院校")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertSchool( - DirectSchoolDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertSchoolAsync(ResolveActor(), request.ToCommand(), cancellationToken)); - } - - [HttpGet("scoreline/majors")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询管理侧分数线专业")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetMajors( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetMajorsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpPut("scoreline/majors")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("新增或更新分数线专业")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertMajor( - DirectMajorDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertMajorAsync(ResolveActor(), request.ToCommand(), cancellationToken)); - } - - [HttpGet("scoreline/fields")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询管理侧分数线字段")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetScorelineFields( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetScorelineFieldsAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("scoreline/fields")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("新增或更新动态分数线字段")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertScorelineField( - DirectScorelineFieldDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertScorelineFieldAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("scoreline/records")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询管理侧分数线记录")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetScorelineRecords( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetScorelineRecordsAsync(ResolveActor(), query.ToFilter(), - cancellationToken)); - } - - [HttpPut("scoreline/records")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("新增或更新分数线记录")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertScorelineRecord( - DirectScorelineRecordDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertScorelineRecordAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("scoreline/years")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询分数线年份")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetScorelineYears( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok( - await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpGet("scoreline/trend")] - [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] - [EndpointSummary("查询分数线趋势摘要")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetScorelineTrend( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok( - await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpGet("videos")] - [Authorize(Policy = BackendPermissions.TenantVideoManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Video)] - [EndpointSummary("查询租户视频解析")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetVideos( - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetVideosAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpPut("videos")] - [Authorize(Policy = BackendPermissions.TenantVideoManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Video)] - [EndpointSummary("新增或更新视频解析")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertVideo( - DirectVideoDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken)); - } - - [HttpPost("question-videos")] - [Authorize(Policy = BackendPermissions.TenantVideoManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.Video)] - [EndpointSummary("绑定题目与解析视频")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> BindQuestionVideo( - DirectQuestionVideoDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(), - cancellationToken)); - } - - [HttpGet("operations/{kind}")] - [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] - [EndpointSummary("查询运营内容")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetOperationContent( - string kind, - [FromQuery] DirectContentQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(), - cancellationToken)); - } - - [HttpPut("operations/{kind}")] - [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] - [EndpointSummary("新增或更新运营内容")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertOperationContent( - string kind, - DirectOperationContentDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(), - cancellationToken)); - } - - [HttpPost("imports/preview/{importType}")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeatureFromRoute("importType")] - [EndpointSummary("预览内容导入数据")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> PreviewImport( - string importType, - DirectImportDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, true), - cancellationToken)); - } - - [HttpPost("imports/{importType}")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [RequireSaasFeatureFromRoute("importType")] - [EndpointSummary("执行或排队内容导入")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task> ExecuteImport( - string importType, - DirectImportDto request, - CancellationToken cancellationToken) - { - var actor = ResolveActor(); - var command = request.ToCommand(importType, false); - if (request.Async == true || command.Items.Count > 100) - { - var job = await backgroundJobService.EnqueueAsync( - new CreateBackgroundJobCommand( - actor.TenantId, - "content_import", - JsonSerializer.SerializeToElement(new - { - createdBy = actor.UserId, - importType = command.ImportType, - sourceFormat = command.SourceFormat, - sourceName = command.SourceName, - regionId = command.RegionId, - entryId = command.EntryId, - contentNodeId = command.ContentNodeId, - subjectId = command.SubjectId, - categoryId = command.CategoryId, - questionBankId = command.QuestionBankId, - collectionId = command.CollectionId, - items = command.Items - })), - cancellationToken); - return Accepted(job); - } - - return Ok(await directContentService.ExecuteImportAsync(actor, command, cancellationToken)); - } - - [HttpGet("imports/detail")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [EndpointSummary("查询内容导入任务详情")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetImportDetail( - [FromQuery] DirectImportJobDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetImportJobAsync(ResolveActor(), query.JobId, cancellationToken)); - } - - [HttpGet("imports/issues")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [EndpointSummary("查询内容导入问题明细")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetImportIssues( - [FromQuery] DirectImportJobDto query, - CancellationToken cancellationToken) - { - return Ok(await directContentService.GetImportIssuesAsync(ResolveActor(), query.JobId, cancellationToken)); - } - - [HttpPost("imports/post-check")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [EndpointSummary("执行内容导入后完整性检查")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> RunImportPostCheck( - DirectImportJobDto request, - CancellationToken cancellationToken) - { - return Ok(await directContentService.RunImportPostCheckAsync(ResolveActor(), request.JobId, cancellationToken)); - } - - [HttpGet("imports/post-check")] - [Authorize(Policy = BackendPermissions.TenantJobManage)] - [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [EndpointSummary("查询内容导入后检查状态")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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); - } -} \ No newline at end of file diff --git a/Tiku.Api/Controllers/VideoManagementController.cs b/Tiku.Api/Controllers/VideoManagementController.cs new file mode 100644 index 0000000..dfc7f9a --- /dev/null +++ b/Tiku.Api/Controllers/VideoManagementController.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class VideoManagementController( + IVideoManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("videos")] + [Authorize(Policy = BackendPermissions.TenantVideoManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] + [EndpointSummary("查询租户视频解析")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVideos( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetVideosAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("videos")] + [Authorize(Policy = BackendPermissions.TenantVideoManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] + [EndpointSummary("新增或更新视频解析")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVideo( + DirectVideoDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertVideoAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("question-videos")] + [Authorize(Policy = BackendPermissions.TenantVideoManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] + [EndpointSummary("绑定题目与解析视频")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> BindQuestionVideo( + DirectQuestionVideoDto request, + CancellationToken cancellationToken) + { + return Ok(await service.BindQuestionVideoAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/VocabularyManagementController.cs b/Tiku.Api/Controllers/VocabularyManagementController.cs new file mode 100644 index 0000000..cf79e17 --- /dev/null +++ b/Tiku.Api/Controllers/VocabularyManagementController.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("租户端-内容直接管理")] +[Produces("application/json")] +[Route("api/tenant/content")] +public sealed class VocabularyManagementController( + IVocabularyManagementService service, + DirectContentActorResolver actorResolver) : ControllerBase +{ + [HttpGet("vocabulary-units")] + [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [EndpointSummary("查询管理侧词汇单元")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVocabularyUnits( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetVocabularyUnitsAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("vocabulary-units")] + [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [EndpointSummary("新增或更新词汇单元")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVocabularyUnit( + DirectVocabularyUnitDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertVocabularyUnitAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } + + [HttpGet("vocabulary-words")] + [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [EndpointSummary("查询管理侧词汇")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVocabularyWords( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetVocabularyWordsAsync(actorResolver.Resolve(), query.ToFilter(), + cancellationToken)); + } + + [HttpPut("vocabulary-words")] + [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] + [Authorize(Policy = TikuPolicies.TenantAllDataScope)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [EndpointSummary("新增或更新词汇")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVocabularyWord( + DirectVocabularyWordDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertVocabularyWordAsync(actorResolver.Resolve(), request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Application/Content/DirectContentModels.cs b/Tiku.Application/Content/DirectContentModels.cs index aa7036b..fff70f7 100644 --- a/Tiku.Application/Content/DirectContentModels.cs +++ b/Tiku.Application/Content/DirectContentModels.cs @@ -317,7 +317,7 @@ public sealed record OperationContentItem( public sealed record ScorelineTrendItem(int Year, int SchoolCount, int MajorCount); -public interface IDirectContentService +public interface IQuestionManagementService { Task> CreateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); @@ -325,6 +325,10 @@ public interface IDirectContentService Task> UpdateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); +} + +public interface IVocabularyManagementService +{ Task> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); @@ -337,6 +341,10 @@ public interface IDirectContentService Task> UpsertVocabularyWordAsync(DirectContentActor actor, VocabularyWordCommand command, CancellationToken cancellationToken = default); +} + +public interface IHandbookManagementService +{ Task> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); @@ -355,6 +363,10 @@ public interface IDirectContentService Task> UpsertHandbookEntryAsync(DirectContentActor actor, HandbookEntryCommand command, CancellationToken cancellationToken = default); +} + +public interface IEducationCatalogManagementService +{ Task> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); @@ -367,6 +379,10 @@ public interface IDirectContentService Task> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default); +} + +public interface IScorelineManagementService +{ Task> GetScorelineFieldsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); @@ -385,6 +401,10 @@ public interface IDirectContentService Task> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); +} + +public interface IVideoManagementService +{ Task> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); @@ -394,12 +414,20 @@ public interface IDirectContentService Task> BindQuestionVideoAsync(DirectContentActor actor, QuestionVideoCommand command, CancellationToken cancellationToken = default); +} + +public interface IOperationContentManagementService +{ Task> GetOperationContentAsync(DirectContentActor actor, string kind, AdminLimitFilter filter, CancellationToken cancellationToken = default); Task> UpsertOperationContentAsync(DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default); +} + +public interface IContentImportService +{ Task PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default); @@ -417,4 +445,4 @@ public interface IDirectContentService Task GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs deleted file mode 100644 index bb860c6..0000000 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text.RegularExpressions; -using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Content; - -public sealed partial class DirectContentService( - TikuDbContext dbContext, - IQuestionReferenceService questionReferenceService, - ICurrentAccessContext currentAccessContext, - IFeatureAccessService featureAccessService) : IDirectContentService -{ - private const int DefaultLimit = 100; - private const int MaxLimit = 1000; - - private static readonly string[] ContentPermissions = - [ - BackendPermissions.TenantContentManage, - BackendPermissions.TenantVocabularyManage, - BackendPermissions.TenantHandbookManage, - BackendPermissions.TenantVideoManage, - BackendPermissions.TenantScorelineManage, - BackendPermissions.TenantSiteContentManage, - BackendPermissions.TenantJobManage - ]; - - private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled); - - private static readonly HashSet SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase) - { - "questions", - "vocabulary", - "handbook", - "scoreline", - "videos" - }; -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs b/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs similarity index 96% rename from Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs rename to Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs index 997dafc..7c42e52 100644 --- a/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs +++ b/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs @@ -6,7 +6,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class EducationCatalogManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IEducationCatalogManagementService { public async Task> GetSchoolsAsync( DirectContentActor actor, @@ -114,4 +115,4 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs similarity index 84% rename from Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs rename to Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs index 19889ad..ea3d072 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs @@ -11,9 +11,9 @@ using Tiku.Domain.QuestionBanks; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal abstract partial class DirectContentServiceBase { - private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) + protected static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) { return new QuestionManagementItem( question.Id, @@ -44,7 +44,7 @@ public sealed partial class DirectContentService question.Status); } - private static VideoManagementItem ToVideoItem(VideoExplanation item) + protected static VideoManagementItem ToVideoItem(VideoExplanation item) { return new VideoManagementItem( item.Id, @@ -63,7 +63,7 @@ public sealed partial class DirectContentService item.Metadata); } - private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) + protected static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) { return new QuestionVideoManagementItem( item.Id, @@ -75,7 +75,7 @@ public sealed partial class DirectContentService item.Metadata); } - private static OperationContentItem ToOperationItem(Banner item) + protected static OperationContentItem ToOperationItem(Banner item) { return new OperationContentItem( item.Id, @@ -101,7 +101,7 @@ public sealed partial class DirectContentService })); } - private static OperationContentItem ToOperationItem(Faq item) + protected static OperationContentItem ToOperationItem(Faq item) { return new OperationContentItem( item.Id, @@ -120,7 +120,7 @@ public sealed partial class DirectContentService JsonDefaults.Object()); } - private static OperationContentItem ToOperationItem(Announcement item) + protected static OperationContentItem ToOperationItem(Announcement item) { return new OperationContentItem( item.Id, @@ -143,7 +143,7 @@ public sealed partial class DirectContentService })); } - private static OperationContentItem ToOperationItem(ExamDate item) + protected static OperationContentItem ToOperationItem(ExamDate item) { return new OperationContentItem( item.Id, @@ -162,7 +162,7 @@ public sealed partial class DirectContentService item.Metadata); } - private static ContentImportJobItem ToJobItem(ContentImportJob job) + protected static ContentImportJobItem ToJobItem(ContentImportJob job) { return new ContentImportJobItem( job.Id, @@ -192,7 +192,7 @@ public sealed partial class DirectContentService job.UpdatedAt); } - private static ContentImportItemModel ToImportItem(ContentImportItem item) + protected static ContentImportItemModel ToImportItem(ContentImportItem item) { return new ContentImportItemModel( item.Id, @@ -208,7 +208,7 @@ public sealed partial class DirectContentService item.IssuesCount); } - private async Task RequireDataScopeAsync( + protected async Task RequireDataScopeAsync( DirectContentActor actor, CancellationToken cancellationToken) { @@ -222,7 +222,7 @@ public sealed partial class DirectContentService return access.DataScope; } - private static void EnsureRegionWriteAllowed( + protected static void EnsureRegionWriteAllowed( CurrentDataScope scope, DirectContentActor actor, Guid? currentRegionId, @@ -236,7 +236,7 @@ public sealed partial class DirectContentService throw new ContentManagementException("Content resource was not found.", notFoundCode); } - private async Task ResolveByIdOrLegacyAsync( + protected async Task ResolveByIdOrLegacyAsync( DbSet set, Guid tenantId, Guid? id, @@ -256,7 +256,7 @@ public sealed partial class DirectContentService cancellationToken); } - private async Task AssertReferenceAsync( + protected async Task AssertReferenceAsync( Guid tenantId, Guid? id, string code, @@ -272,7 +272,7 @@ public sealed partial class DirectContentService if (!exists) throw new ContentManagementException("Referenced entity was not found.", code); } - private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) + protected async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) { var exists = await dbContext.ContentImportJobs.AnyAsync( item => item.TenantId == tenantId && item.Id == jobId, @@ -280,7 +280,7 @@ public sealed partial class DirectContentService if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); } - private static string NormalizeOperationKind(string kind) + protected static string NormalizeOperationKind(string kind) { var normalized = Normalize(kind)?.ToLowerInvariant(); return normalized switch @@ -293,7 +293,7 @@ public sealed partial class DirectContentService }; } - private static ContentImportType ParseImportType(string value) + protected static ContentImportType ParseImportType(string value) { return value.ToLowerInvariant() switch { @@ -306,7 +306,7 @@ public sealed partial class DirectContentService }; } - private static TEnum Parse(string? value, TEnum fallback, string code) + protected static TEnum Parse(string? value, TEnum fallback, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) return fallback; @@ -316,7 +316,7 @@ public sealed partial class DirectContentService throw new ContentManagementException("Enum value is invalid.", code); } - private static TEnum? ParseNullable(string? value, string code) + protected static TEnum? ParseNullable(string? value, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) return null; @@ -326,41 +326,41 @@ public sealed partial class DirectContentService throw new ContentManagementException("Enum value is invalid.", code); } - private static string? Normalize(string? value) + protected static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private static int ResolveLimit(int? limit) + protected static int ResolveLimit(int? limit) { return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit); } - private static JsonElement JsonObjectOrDefault(JsonElement value) + protected static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object(); } - private static JsonElement JsonArrayOrDefault(JsonElement value) + protected static JsonElement JsonArrayOrDefault(JsonElement value) { return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array(); } - private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) + protected static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) { return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) ? value : fallback; } - private static string? GetString(JsonElement payload, string name) + protected static string? GetString(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); } - private static int? GetInt(JsonElement payload, string name) + protected static int? GetInt(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; @@ -371,7 +371,7 @@ public sealed partial class DirectContentService : null; } - private static Guid? GetGuid(JsonElement payload, string name) + protected static Guid? GetGuid(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; @@ -381,7 +381,7 @@ public sealed partial class DirectContentService : null; } - private static bool? GetBool(JsonElement payload, string name) + protected static bool? GetBool(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; @@ -393,4 +393,4 @@ public sealed partial class DirectContentService _ => null }; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs similarity index 54% rename from Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs rename to Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs index fd6dac7..ee1f4db 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs @@ -12,191 +12,9 @@ using Tiku.Domain.QuestionBanks; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal abstract partial class DirectContentServiceBase { - private async Task CreateImportJobAsync( - DirectContentActor actor, - SimpleImportCommand command, - bool execute, - CancellationToken cancellationToken) - { - if (!SupportedImportTypes.Contains(command.ImportType)) - throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); - - var importType = ParseImportType(command.ImportType); - var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); - var items = command.Items - .Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); - var job = new ContentImportJob - { - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - TargetRegionId = command.RegionId, - TargetSubjectId = command.SubjectId, - TargetCategoryId = command.CategoryId, - TargetContentNodeId = command.ContentNodeId, - TargetQuestionBankId = command.QuestionBankId, - ImportType = importType, - SourceFormat = sourceFormat, - Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, - SourceName = Normalize(command.SourceName), - DryRun = command.DryRun, - TotalCount = items.Length, - ValidCount = items.Length, - RawPayload = JsonSerializer.SerializeToElement(items), - NormalizedPayload = JsonSerializer.SerializeToElement(items), - StartedAt = execute ? DateTimeOffset.UtcNow : null, - FinishedAt = execute ? DateTimeOffset.UtcNow : null - }; - dbContext.ContentImportJobs.Add(job); - - var importItems = new List(); - var rowNo = 1; - foreach (var payload in items) - { - var importItem = new ContentImportItem - { - TenantId = actor.TenantId, - JobId = job.Id, - RowNo = rowNo++, - ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), - Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, - SourcePayload = payload, - NormalizedPayload = payload - }; - - if (execute) - { - var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); - importItem.TargetType = target.TargetType; - importItem.TargetId = target.TargetId; - job.InsertedCount++; - } - - importItems.Add(importItem); - } - - dbContext.ContentImportItems.AddRange(importItems); - job.Summary = JsonSerializer.SerializeToElement(new - { - mode = execute ? "execute" : "preview", - supportedTypes = SupportedImportTypes, - note = "Synchronous direct migration import skeleton; async worker will be introduced later." - }); - - await dbContext.SaveChangesAsync(cancellationToken); - return new SimpleImportResult( - ToJobItem(job), - importItems.Select(ToImportItem).ToArray(), - []); - } - - private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( - DirectContentActor actor, - SimpleImportCommand command, - JsonElement payload, - CancellationToken cancellationToken) - { - switch (command.ImportType.ToLowerInvariant()) - { - case "questions": - var result = await CreateQuestionAsync(actor, new QuestionWriteCommand( - null, - command.QuestionBankId, - command.SubjectId, - command.CategoryId, - null, - command.EntryId, - command.ContentNodeId, - command.CollectionId, - GetString(payload, "legacyId"), - GetString(payload, "type") ?? "choice", - GetString(payload, "typeLabel"), - GetInt(payload, "difficulty"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetString(payload, "content") ?? GetString(payload, "title"), - GetElement(payload, "options", JsonDefaults.Array()), - GetInt(payload, "correctOptionIndex"), - GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), - GetString(payload, "answerText") ?? GetString(payload, "answer"), - GetString(payload, "explanation"), - GetElement(payload, "subQuestions", JsonDefaults.Array()), - GetString(payload, "codeLang"), - GetString(payload, "codeTemplate"), - GetString(payload, "mediaUrl"), - "Published", - GetElement(payload, "examMarkers", JsonDefaults.Object()), - GetString(payload, "sourceHash"), - true), cancellationToken); - return ("question", result.Item.Id); - case "vocabulary": - var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( - null, - null, - command.EntryId, - command.ContentNodeId, - GetString(payload, "legacyId"), - GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", - GetString(payload, "phonetic"), - GetString(payload, "meaning"), - GetString(payload, "example"), - GetString(payload, "exampleTranslation"), - GetInt(payload, "difficulty"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("vocabulary_word", word.Item.Id); - case "handbook": - var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( - null, - null, - command.EntryId, - command.ContentNodeId, - GetString(payload, "legacyId"), - GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", - GetString(payload, "summary"), - GetString(payload, "content"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("handbook_entry", entry.Item.Id); - case "scoreline": - var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand( - null, - command.RegionId, - GetGuid(payload, "schoolId"), - GetGuid(payload, "majorId"), - GetString(payload, "legacyId"), - GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year, - GetString(payload, "schoolName"), - GetString(payload, "majorName"), - GetElement(payload, "fieldValues", payload)), cancellationToken); - return ("scoreline_record", scoreline.Item.Id); - case "videos": - var video = await UpsertVideoAsync(actor, new VideoExplanationCommand( - null, - command.SubjectId, - GetString(payload, "legacyId"), - GetString(payload, "title") ?? "未命名视频", - GetString(payload, "description"), - GetString(payload, "videoUrl") ?? GetString(payload, "url"), - GetString(payload, "thumbnailUrl"), - GetInt(payload, "durationSeconds"), - GetElement(payload, "knowledgeTags", JsonDefaults.Array()), - GetBool(payload, "isGeneral"), - GetInt(payload, "difficulty"), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("video_explanation", video.Item.Id); - default: - throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); - } - } - - private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, + protected async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); @@ -221,7 +39,7 @@ public sealed partial class DirectContentService return item; } - private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, + protected async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); @@ -241,7 +59,7 @@ public sealed partial class DirectContentService return item; } - private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, + protected async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, @@ -260,7 +78,7 @@ public sealed partial class DirectContentService return item; } - private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, + protected async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); @@ -286,7 +104,7 @@ public sealed partial class DirectContentService return item; } - private static void ApplyQuestion(Question question, QuestionWriteCommand command) + protected static void ApplyQuestion(Question question, QuestionWriteCommand command) { question.QuestionBankId = command.QuestionBankId; question.SubjectId = command.SubjectId; @@ -305,7 +123,7 @@ public sealed partial class DirectContentService question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); } - private static void ValidateQuestionForPublication(QuestionWriteCommand command) + protected static void ValidateQuestionForPublication(QuestionWriteCommand command) { var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); if (status != QuestionStatus.Published) return; @@ -321,7 +139,7 @@ public sealed partial class DirectContentService "question_grading_rule_invalid"); } - private static QuestionVersion BuildQuestionVersion( + protected static QuestionVersion BuildQuestionVersion( DirectContentActor actor, Guid questionId, int versionNo, @@ -338,7 +156,7 @@ public sealed partial class DirectContentService return version; } - private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) + protected static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) { version.Content = Normalize(command.Content); version.Options = JsonArrayOrDefault(command.Options); @@ -352,7 +170,7 @@ public sealed partial class DirectContentService version.SourceHash = Normalize(command.SourceHash); } - private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, + protected async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", @@ -366,7 +184,7 @@ public sealed partial class DirectContentService cancellationToken); } - private async Task SyncPrimaryCollectionItemAsync( + protected async Task SyncPrimaryCollectionItemAsync( DirectContentActor actor, Question question, CancellationToken cancellationToken) @@ -411,7 +229,7 @@ public sealed partial class DirectContentService collection.UpdatedBy = actor.UserId; } - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( + protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( Guid tenantId, Guid? unitId, Guid? entryId, @@ -429,7 +247,7 @@ public sealed partial class DirectContentService return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); } - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( + protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( Guid tenantId, Guid? subjectId, Guid? entryId, @@ -447,7 +265,7 @@ public sealed partial class DirectContentService return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); } - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( + protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( Guid tenantId, Guid? chapterId, Guid? entryId, @@ -464,4 +282,4 @@ public sealed partial class DirectContentService return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs new file mode 100644 index 0000000..8d9d5fd --- /dev/null +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs @@ -0,0 +1,46 @@ +using System.Text.RegularExpressions; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Content; + +internal sealed record DirectContentServiceDependencies( + TikuDbContext DbContext, + IQuestionReferenceService QuestionReferenceService, + ICurrentAccessContext CurrentAccessContext, + IFeatureAccessService FeatureAccessService); + +internal abstract partial class DirectContentServiceBase(DirectContentServiceDependencies dependencies) +{ + protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService; + protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext; + protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService; + + protected const int DefaultLimit = 100; + protected const int MaxLimit = 1000; + + protected static readonly string[] ContentPermissions = + [ + BackendPermissions.TenantContentManage, + BackendPermissions.TenantVocabularyManage, + BackendPermissions.TenantHandbookManage, + BackendPermissions.TenantVideoManage, + BackendPermissions.TenantScorelineManage, + BackendPermissions.TenantSiteContentManage, + BackendPermissions.TenantJobManage + ]; + + protected static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled); + + protected static readonly HashSet SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase) + { + "questions", + "vocabulary", + "handbook", + "scoreline", + "videos" + }; +} diff --git a/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs b/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs similarity index 98% rename from Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs rename to Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs index 73d9e15..10d4f40 100644 --- a/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs +++ b/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs @@ -7,7 +7,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class HandbookManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IHandbookManagementService { public async Task> GetHandbookSubjectsAsync( DirectContentActor actor, @@ -221,4 +222,4 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Imports/ContentImportJobHandler.cs b/Tiku.Infrastructure/Content/Imports/ContentImportJobHandler.cs index b79d442..c831dc6 100644 --- a/Tiku.Infrastructure/Content/Imports/ContentImportJobHandler.cs +++ b/Tiku.Infrastructure/Content/Imports/ContentImportJobHandler.cs @@ -5,7 +5,7 @@ using Tiku.Infrastructure.Jobs; namespace Tiku.Infrastructure.Content.Imports; -internal sealed class ContentImportJobHandler(IDirectContentService directContentService) : IBackgroundJobHandler +internal sealed class ContentImportJobHandler(IContentImportService contentImportService) : IBackgroundJobHandler { public string JobType => "content_import"; @@ -30,7 +30,7 @@ internal sealed class ContentImportJobHandler(IDirectContentService directConten BackgroundJobPayload.GetGuid(context.Payload, "collectionId"), BackgroundJobPayload.GetArray(context.Payload, "items"), false); - var result = await directContentService.ExecuteImportAsync( + var result = await contentImportService.ExecuteImportAsync( new DirectContentActor(context.TenantId, createdBy), command, cancellationToken); diff --git a/Tiku.Infrastructure/Content/Imports/ContentImportService.cs b/Tiku.Infrastructure/Content/Imports/ContentImportService.cs new file mode 100644 index 0000000..b378963 --- /dev/null +++ b/Tiku.Infrastructure/Content/Imports/ContentImportService.cs @@ -0,0 +1,339 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; + +namespace Tiku.Infrastructure.Content; + +internal sealed class ContentImportService( + DirectContentServiceDependencies dependencies, + IQuestionManagementService questionService, + IVocabularyManagementService vocabularyService, + IHandbookManagementService handbookService, + IScorelineManagementService scorelineService, + IVideoManagementService videoService) + : DirectContentServiceBase(dependencies), IContentImportService +{ + public Task PreviewImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken); + } + + public Task ExecuteImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken); + } + + public async Task GetImportJobAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) + .Select(item => ToJobItem(item)) + .SingleOrDefaultAsync(cancellationToken); + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + + var items = await dbContext.ContentImportItems.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .Take(MaxLimit) + .Select(item => ToImportItem(item)) + .ToArrayAsync(cancellationToken); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) + .OrderBy(issue => issue.RowNo) + .ThenBy(issue => issue.CreatedAt) + .Take(MaxLimit) + .Select(issue => new ContentImportIssueModel( + issue.Id, + issue.JobId, + issue.ItemId, + issue.RowNo, + issue.Severity, + issue.Code, + issue.FieldPath, + issue.Message, + issue.Details)) + .ToArrayAsync(cancellationToken); + + return new ContentImportJobDetail(job, items, issues); + } + + public async Task> GetImportIssuesAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .ThenBy(item => item.CreatedAt) + .Take(MaxLimit) + .Select(item => new ContentImportIssueModel( + item.Id, + item.JobId, + item.ItemId, + item.RowNo, + item.Severity, + item.Code, + item.FieldPath, + item.Message, + item.Details)) + .ToArrayAsync(cancellationToken); + return new CatalogList(issues); + } + + public async Task RunImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + job.Summary = JsonSerializer.SerializeToElement(new + { + postCheck = new + { + status = job.ErrorCount == 0 ? "passed" : "warning", + checkedAt = DateTimeOffset.UtcNow, + counts + } + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); + } + + public async Task GetImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + + var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); + } + private async Task CreateImportJobAsync( + DirectContentActor actor, + SimpleImportCommand command, + bool execute, + CancellationToken cancellationToken) + { + if (!SupportedImportTypes.Contains(command.ImportType)) + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + + var importType = ParseImportType(command.ImportType); + var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); + var items = command.Items + .Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); + var job = new ContentImportJob + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + TargetRegionId = command.RegionId, + TargetSubjectId = command.SubjectId, + TargetCategoryId = command.CategoryId, + TargetContentNodeId = command.ContentNodeId, + TargetQuestionBankId = command.QuestionBankId, + ImportType = importType, + SourceFormat = sourceFormat, + Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, + SourceName = Normalize(command.SourceName), + DryRun = command.DryRun, + TotalCount = items.Length, + ValidCount = items.Length, + RawPayload = JsonSerializer.SerializeToElement(items), + NormalizedPayload = JsonSerializer.SerializeToElement(items), + StartedAt = execute ? DateTimeOffset.UtcNow : null, + FinishedAt = execute ? DateTimeOffset.UtcNow : null + }; + dbContext.ContentImportJobs.Add(job); + + var importItems = new List(); + var rowNo = 1; + foreach (var payload in items) + { + var importItem = new ContentImportItem + { + TenantId = actor.TenantId, + JobId = job.Id, + RowNo = rowNo++, + ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), + Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, + SourcePayload = payload, + NormalizedPayload = payload + }; + + if (execute) + { + var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); + importItem.TargetType = target.TargetType; + importItem.TargetId = target.TargetId; + job.InsertedCount++; + } + + importItems.Add(importItem); + } + + dbContext.ContentImportItems.AddRange(importItems); + job.Summary = JsonSerializer.SerializeToElement(new + { + mode = execute ? "execute" : "preview", + supportedTypes = SupportedImportTypes, + note = "Synchronous direct migration import skeleton; async worker will be introduced later." + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return new SimpleImportResult( + ToJobItem(job), + importItems.Select(ToImportItem).ToArray(), + []); + } + + private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( + DirectContentActor actor, + SimpleImportCommand command, + JsonElement payload, + CancellationToken cancellationToken) + { + switch (command.ImportType.ToLowerInvariant()) + { + case "questions": + var result = await questionService.CreateQuestionAsync(actor, new QuestionWriteCommand( + null, + command.QuestionBankId, + command.SubjectId, + command.CategoryId, + null, + command.EntryId, + command.ContentNodeId, + command.CollectionId, + GetString(payload, "legacyId"), + GetString(payload, "type") ?? "choice", + GetString(payload, "typeLabel"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetString(payload, "content") ?? GetString(payload, "title"), + GetElement(payload, "options", JsonDefaults.Array()), + GetInt(payload, "correctOptionIndex"), + GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), + GetString(payload, "answerText") ?? GetString(payload, "answer"), + GetString(payload, "explanation"), + GetElement(payload, "subQuestions", JsonDefaults.Array()), + GetString(payload, "codeLang"), + GetString(payload, "codeTemplate"), + GetString(payload, "mediaUrl"), + "Published", + GetElement(payload, "examMarkers", JsonDefaults.Object()), + GetString(payload, "sourceHash"), + true), cancellationToken); + return ("question", result.Item.Id); + case "vocabulary": + var word = await vocabularyService.UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", + GetString(payload, "phonetic"), + GetString(payload, "meaning"), + GetString(payload, "example"), + GetString(payload, "exampleTranslation"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("vocabulary_word", word.Item.Id); + case "handbook": + var entry = await handbookService.UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", + GetString(payload, "summary"), + GetString(payload, "content"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("handbook_entry", entry.Item.Id); + case "scoreline": + var scoreline = await scorelineService.UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand( + null, + command.RegionId, + GetGuid(payload, "schoolId"), + GetGuid(payload, "majorId"), + GetString(payload, "legacyId"), + GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year, + GetString(payload, "schoolName"), + GetString(payload, "majorName"), + GetElement(payload, "fieldValues", payload)), cancellationToken); + return ("scoreline_record", scoreline.Item.Id); + case "videos": + var video = await videoService.UpsertVideoAsync(actor, new VideoExplanationCommand( + null, + command.SubjectId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? "未命名视频", + GetString(payload, "description"), + GetString(payload, "videoUrl") ?? GetString(payload, "url"), + GetString(payload, "thumbnailUrl"), + GetInt(payload, "durationSeconds"), + GetElement(payload, "knowledgeTags", JsonDefaults.Array()), + GetBool(payload, "isGeneral"), + GetInt(payload, "difficulty"), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("video_explanation", video.Item.Id); + default: + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + } + } +} diff --git a/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs b/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs deleted file mode 100644 index 88bf7ac..0000000 --- a/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; -using Tiku.Application.Catalog; -using Tiku.Application.Content; - -namespace Tiku.Infrastructure.Content; - -public sealed partial class DirectContentService -{ - public Task PreviewImportAsync( - DirectContentActor actor, - SimpleImportCommand command, - CancellationToken cancellationToken = default) - { - return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken); - } - - public Task ExecuteImportAsync( - DirectContentActor actor, - SimpleImportCommand command, - CancellationToken cancellationToken = default) - { - return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken); - } - - public async Task GetImportJobAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) - .Select(item => ToJobItem(item)) - .SingleOrDefaultAsync(cancellationToken); - if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - - var items = await dbContext.ContentImportItems.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) - .OrderBy(item => item.RowNo) - .Take(MaxLimit) - .Select(item => ToImportItem(item)) - .ToArrayAsync(cancellationToken); - var issues = await dbContext.ContentImportIssues.AsNoTracking() - .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) - .OrderBy(issue => issue.RowNo) - .ThenBy(issue => issue.CreatedAt) - .Take(MaxLimit) - .Select(issue => new ContentImportIssueModel( - issue.Id, - issue.JobId, - issue.ItemId, - issue.RowNo, - issue.Severity, - issue.Code, - issue.FieldPath, - issue.Message, - issue.Details)) - .ToArrayAsync(cancellationToken); - - return new ContentImportJobDetail(job, items, issues); - } - - public async Task> GetImportIssuesAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); - var issues = await dbContext.ContentImportIssues.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) - .OrderBy(item => item.RowNo) - .ThenBy(item => item.CreatedAt) - .Take(MaxLimit) - .Select(item => new ContentImportIssueModel( - item.Id, - item.JobId, - item.ItemId, - item.RowNo, - item.Severity, - item.Code, - item.FieldPath, - item.Message, - item.Details)) - .ToArrayAsync(cancellationToken); - return new CatalogList(issues); - } - - public async Task RunImportPostCheckAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == jobId, - cancellationToken); - if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - - var counts = JsonSerializer.SerializeToElement(new - { - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount - }); - job.Summary = JsonSerializer.SerializeToElement(new - { - postCheck = new - { - status = job.ErrorCount == 0 ? "passed" : "warning", - checkedAt = DateTimeOffset.UtcNow, - counts - } - }); - await dbContext.SaveChangesAsync(cancellationToken); - return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); - } - - public async Task GetImportPostCheckAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == jobId, - cancellationToken); - if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - - var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); - var counts = JsonSerializer.SerializeToElement(new - { - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount - }); - return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); - } -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs b/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs similarity index 95% rename from Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs rename to Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs index 9e1913f..d19f371 100644 --- a/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs +++ b/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs @@ -4,7 +4,8 @@ using Tiku.Application.Content; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class OperationContentManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IOperationContentManagementService { public async Task> GetOperationContentAsync( DirectContentActor actor, @@ -72,4 +73,4 @@ public sealed partial class DirectContentService return new ContentManagementResult(item); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs b/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs similarity index 96% rename from Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs rename to Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs index d68287d..241c9a9 100644 --- a/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs +++ b/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.QuestionBanks; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class QuestionManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IQuestionManagementService { public async Task> CreateQuestionAsync( DirectContentActor actor, @@ -112,4 +113,4 @@ public sealed partial class DirectContentService if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs b/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs similarity index 98% rename from Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs rename to Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs index 9bcc9dd..30484e3 100644 --- a/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs +++ b/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs @@ -6,7 +6,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class ScorelineManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IScorelineManagementService { public async Task> GetScorelineFieldsAsync( DirectContentActor actor, @@ -185,4 +186,4 @@ public sealed partial class DirectContentService return new CatalogList(items); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs similarity index 96% rename from Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs rename to Tiku.Infrastructure/Content/Videos/VideoManagementService.cs index dcc70bd..cd30292 100644 --- a/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs +++ b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs @@ -7,7 +7,8 @@ using Tiku.Domain.QuestionBanks; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class VideoManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IVideoManagementService { public async Task> GetVideosAsync( DirectContentActor actor, @@ -96,4 +97,4 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionVideoItem(item)); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs b/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs similarity index 97% rename from Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs rename to Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs index fc0c529..77516b4 100644 --- a/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs +++ b/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs @@ -7,7 +7,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class DirectContentService +internal sealed class VocabularyManagementService(DirectContentServiceDependencies dependencies) + : DirectContentServiceBase(dependencies), IVocabularyManagementService { public async Task> GetVocabularyUnitsAsync( DirectContentActor actor, @@ -147,4 +148,4 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Modules/ContentModule.cs b/Tiku.Infrastructure/Modules/ContentModule.cs index 4e16c48..6789451 100644 --- a/Tiku.Infrastructure/Modules/ContentModule.cs +++ b/Tiku.Infrastructure/Modules/ContentModule.cs @@ -30,7 +30,15 @@ internal static class ContentModule services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index 71c240e..f416713 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -35,7 +35,6 @@ public sealed class ArchitectureBoundaryTests var root = FindRepositoryRoot(); var legacyLineBudgets = new Dictionary(StringComparer.Ordinal) { - ["DirectContentService"] = 2015, ["CommerceAdminService"] = 1842, ["LearningActivityService"] = 1781, ["PlatformAdminService"] = 1719,