refactor(content): split content capability services

This commit is contained in:
2026-08-04 09:23:36 +08:00
parent 1b867cfa3a
commit 4d85463a87
28 changed files with 1160 additions and 929 deletions

View File

@@ -23,6 +23,7 @@ internal static class ApiPresentationExtensions
});
services.AddProblemDetails();
services.AddScoped<TenantAdminActorResolver>();
services.AddScoped<DirectContentActorResolver>();
return services;
}

View File

@@ -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<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> 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<SimpleImportResult>(StatusCodes.Status200OK)]
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<object>> 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<ContentImportJobDetail>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentImportJobDetail>> 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<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> 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<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> 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<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
[FromQuery] DirectImportJobDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetImportPostCheckAsync(actorResolver.Resolve(), query.JobId, cancellationToken));
}
}

View File

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

View File

@@ -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<CatalogList<School>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<School>>> 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<ContentManagementResult<School>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<School>>> 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<CatalogList<Major>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<Major>>> 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<ContentManagementResult<Major>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<Major>>> UpsertMajor(
DirectMajorDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertMajorAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken));
}
}

View File

@@ -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<CatalogList<HandbookSubject>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookSubject>>> 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<ContentManagementResult<HandbookSubject>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookSubject>>> 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<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookChapter>>> 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<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> 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<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookEntry>>> 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<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
DirectHandbookEntryDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertHandbookEntryAsync(actorResolver.Resolve(), request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<OperationContentItem>>> 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<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
string kind,
DirectOperationContentDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertOperationContentAsync(actorResolver.Resolve(), kind, request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> 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<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
DirectQuestionWriteDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpdateQuestionAsync(actorResolver.Resolve(), request.ToCommand(false),
cancellationToken));
}
}

View File

@@ -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<CatalogList<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineField>>> 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<ContentManagementResult<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineField>>> 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<CatalogList<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineRecord>>> 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<ContentManagementResult<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineRecord>>> 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<CatalogList<int>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<int>>> 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<CatalogList<ScorelineTrendItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineTrendItem>>> GetScorelineTrend(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(
await service.GetScorelineTrendAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
}
}

View File

@@ -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<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> 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<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> 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<CatalogList<VocabularyUnit>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyUnit>>> GetVocabularyUnits(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(),
cancellationToken));
}
[HttpPut("vocabulary-units")]
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
[RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
[EndpointSummary("新增或更新词汇单元")]
[ProducesResponseType<ContentManagementResult<VocabularyUnit>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyUnit>>> UpsertVocabularyUnit(
DirectVocabularyUnitDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("vocabulary-words")]
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
[EndpointSummary("查询管理侧词汇")]
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(),
cancellationToken));
}
[HttpPut("vocabulary-words")]
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
[EndpointSummary("新增或更新词汇")]
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
DirectVocabularyWordDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("handbook-subjects")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("查询管理侧知识手册科目")]
[ProducesResponseType<CatalogList<HandbookSubject>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookSubject>>> GetHandbookSubjects(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(),
cancellationToken));
}
[HttpPut("handbook-subjects")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("新增或更新知识手册科目")]
[ProducesResponseType<ContentManagementResult<HandbookSubject>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookSubject>>> UpsertHandbookSubject(
DirectHandbookSubjectDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("handbook-chapters")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("查询管理侧知识手册章节")]
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(),
cancellationToken));
}
[HttpPut("handbook-chapters")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("新增或更新知识手册章节")]
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
DirectHandbookChapterDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("handbook-entries")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("查询管理侧知识手册条目")]
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(),
cancellationToken));
}
[HttpPut("handbook-entries")]
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
[EndpointSummary("新增或更新知识手册条目")]
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
DirectHandbookEntryDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("scoreline/schools")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("查询管理侧分数线院校")]
[ProducesResponseType<CatalogList<School>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<School>>> GetSchools(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetSchoolsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("scoreline/schools")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("新增或更新分数线院校")]
[ProducesResponseType<ContentManagementResult<School>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<School>>> UpsertSchool(
DirectSchoolDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertSchoolAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("scoreline/majors")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("查询管理侧分数线专业")]
[ProducesResponseType<CatalogList<Major>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<Major>>> GetMajors(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetMajorsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("scoreline/majors")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("新增或更新分数线专业")]
[ProducesResponseType<ContentManagementResult<Major>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<Major>>> UpsertMajor(
DirectMajorDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertMajorAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("scoreline/fields")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("查询管理侧分数线字段")]
[ProducesResponseType<CatalogList<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineField>>> 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<ContentManagementResult<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineField>>> 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<CatalogList<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineRecord>>> 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<ContentManagementResult<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineRecord>>> 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<CatalogList<int>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<int>>> GetScorelineYears(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(
await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("scoreline/trend")]
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
[EndpointSummary("查询分数线趋势摘要")]
[ProducesResponseType<CatalogList<ScorelineTrendItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineTrendItem>>> GetScorelineTrend(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(
await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("videos")]
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Video)]
[EndpointSummary("查询租户视频解析")]
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetVideosAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("videos")]
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Video)]
[EndpointSummary("新增或更新视频解析")]
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
DirectVideoDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("question-videos")]
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.Video)]
[EndpointSummary("绑定题目与解析视频")]
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
DirectQuestionVideoDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(),
cancellationToken));
}
[HttpGet("operations/{kind}")]
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
[EndpointSummary("查询运营内容")]
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
string kind,
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(),
cancellationToken));
}
[HttpPut("operations/{kind}")]
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
[EndpointSummary("新增或更新运营内容")]
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
string kind,
DirectOperationContentDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(),
cancellationToken));
}
[HttpPost("imports/preview/{importType}")]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeatureFromRoute("importType")]
[EndpointSummary("预览内容导入数据")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
string importType,
DirectImportDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, true),
cancellationToken));
}
[HttpPost("imports/{importType}")]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[RequireSaasFeatureFromRoute("importType")]
[EndpointSummary("执行或排队内容导入")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<object>> 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<ContentImportJobDetail>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentImportJobDetail>> GetImportDetail(
[FromQuery] DirectImportJobDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetImportJobAsync(ResolveActor(), query.JobId, cancellationToken));
}
[HttpGet("imports/issues")]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[EndpointSummary("查询内容导入问题明细")]
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
[FromQuery] DirectImportJobDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetImportIssuesAsync(ResolveActor(), query.JobId, cancellationToken));
}
[HttpPost("imports/post-check")]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[EndpointSummary("执行内容导入后完整性检查")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
DirectImportJobDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.RunImportPostCheckAsync(ResolveActor(), request.JobId, cancellationToken));
}
[HttpGet("imports/post-check")]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
[EndpointSummary("查询内容导入后检查状态")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
[FromQuery] DirectImportJobDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetImportPostCheckAsync(ResolveActor(), query.JobId, cancellationToken));
}
private DirectContentActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
throw new ContentManagementException("Tenant content actor was not resolved.",
"tenant_content_access_denied");
return new DirectContentActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
}

View File

@@ -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<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VideoManagementItem>>> 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<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> 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<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
DirectQuestionVideoDto request,
CancellationToken cancellationToken)
{
return Ok(await service.BindQuestionVideoAsync(actorResolver.Resolve(), request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<CatalogList<VocabularyUnit>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyUnit>>> 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<ContentManagementResult<VocabularyUnit>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyUnit>>> 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<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyWord>>> 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<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
DirectVocabularyWordDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpsertVocabularyWordAsync(actorResolver.Resolve(), request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<ContentManagementResult<QuestionManagementItem>> CreateQuestionAsync(DirectContentActor actor,
QuestionWriteCommand command, CancellationToken cancellationToken = default);
@@ -325,6 +325,10 @@ public interface IDirectContentService
Task<ContentManagementResult<QuestionManagementItem>> UpdateQuestionAsync(DirectContentActor actor,
QuestionWriteCommand command, CancellationToken cancellationToken = default);
}
public interface IVocabularyManagementService
{
Task<CatalogList<VocabularyUnit>> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
@@ -337,6 +341,10 @@ public interface IDirectContentService
Task<ContentManagementResult<VocabularyWord>> UpsertVocabularyWordAsync(DirectContentActor actor,
VocabularyWordCommand command, CancellationToken cancellationToken = default);
}
public interface IHandbookManagementService
{
Task<CatalogList<HandbookSubject>> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
@@ -355,6 +363,10 @@ public interface IDirectContentService
Task<ContentManagementResult<HandbookEntry>> UpsertHandbookEntryAsync(DirectContentActor actor,
HandbookEntryCommand command, CancellationToken cancellationToken = default);
}
public interface IEducationCatalogManagementService
{
Task<CatalogList<School>> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
@@ -367,6 +379,10 @@ public interface IDirectContentService
Task<ContentManagementResult<Major>> UpsertMajorAsync(DirectContentActor actor, MajorCommand command,
CancellationToken cancellationToken = default);
}
public interface IScorelineManagementService
{
Task<CatalogList<ScorelineField>> GetScorelineFieldsAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
@@ -385,6 +401,10 @@ public interface IDirectContentService
Task<CatalogList<ScorelineTrendItem>> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
}
public interface IVideoManagementService
{
Task<CatalogList<VideoManagementItem>> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter,
CancellationToken cancellationToken = default);
@@ -394,12 +414,20 @@ public interface IDirectContentService
Task<ContentManagementResult<QuestionVideoManagementItem>> BindQuestionVideoAsync(DirectContentActor actor,
QuestionVideoCommand command, CancellationToken cancellationToken = default);
}
public interface IOperationContentManagementService
{
Task<CatalogList<OperationContentItem>> GetOperationContentAsync(DirectContentActor actor, string kind,
AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<ContentManagementResult<OperationContentItem>> UpsertOperationContentAsync(DirectContentActor actor,
string kind, OperationContentCommand command, CancellationToken cancellationToken = default);
}
public interface IContentImportService
{
Task<SimpleImportResult> PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command,
CancellationToken cancellationToken = default);
@@ -417,4 +445,4 @@ public interface IDirectContentService
Task<ImportPostCheckResult> GetImportPostCheckAsync(DirectContentActor actor, Guid jobId,
CancellationToken cancellationToken = default);
}
}

View File

@@ -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<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
{
"questions",
"vocabulary",
"handbook",
"scoreline",
"videos"
};
}

View File

@@ -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<CatalogList<School>> GetSchoolsAsync(
DirectContentActor actor,
@@ -114,4 +115,4 @@ public sealed partial class DirectContentService
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<Major>(item);
}
}
}

View File

@@ -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<CurrentDataScope> RequireDataScopeAsync(
protected async Task<CurrentDataScope> 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<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
protected async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
@@ -256,7 +256,7 @@ public sealed partial class DirectContentService
cancellationToken);
}
private async Task AssertReferenceAsync<TEntity>(
protected async Task AssertReferenceAsync<TEntity>(
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<TEnum>(string? value, TEnum fallback, string code)
protected static TEnum Parse<TEnum>(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<TEnum>(string? value, string code)
protected static TEnum? ParseNullable<TEnum>(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
};
}
}
}

View File

@@ -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<SimpleImportResult> 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<ContentImportItem>();
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<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
@@ -221,7 +39,7 @@ public sealed partial class DirectContentService
return item;
}
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
@@ -241,7 +59,7 @@ public sealed partial class DirectContentService
return item;
}
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<Announcement> 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<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<ExamDate> 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<QuestionBank>(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);
}
}
}

View File

@@ -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<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
{
"questions",
"vocabulary",
"handbook",
"scoreline",
"videos"
};
}

View File

@@ -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<CatalogList<HandbookSubject>> GetHandbookSubjectsAsync(
DirectContentActor actor,
@@ -221,4 +222,4 @@ public sealed partial class DirectContentService
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<HandbookEntry>(item);
}
}
}

View File

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

View File

@@ -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<SimpleImportResult> PreviewImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken);
}
public Task<SimpleImportResult> ExecuteImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken);
}
public async Task<ContentImportJobDetail> 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<CatalogList<ContentImportIssueModel>> 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<ContentImportIssueModel>(issues);
}
public async Task<ImportPostCheckResult> 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<ImportPostCheckResult> 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<SimpleImportResult> 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<ContentImportItem>();
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");
}
}
}

View File

@@ -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<SimpleImportResult> PreviewImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken);
}
public Task<SimpleImportResult> ExecuteImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken);
}
public async Task<ContentImportJobDetail> 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<CatalogList<ContentImportIssueModel>> 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<ContentImportIssueModel>(issues);
}
public async Task<ImportPostCheckResult> 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<ImportPostCheckResult> 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);
}
}

View File

@@ -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<CatalogList<OperationContentItem>> GetOperationContentAsync(
DirectContentActor actor,
@@ -72,4 +73,4 @@ public sealed partial class DirectContentService
return new ContentManagementResult<OperationContentItem>(item);
}
}
}

View File

@@ -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<ContentManagementResult<QuestionManagementItem>> CreateQuestionAsync(
DirectContentActor actor,
@@ -112,4 +113,4 @@ public sealed partial class DirectContentService
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
}
}
}

View File

@@ -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<CatalogList<ScorelineField>> GetScorelineFieldsAsync(
DirectContentActor actor,
@@ -185,4 +186,4 @@ public sealed partial class DirectContentService
return new CatalogList<ScorelineTrendItem>(items);
}
}
}

View File

@@ -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<CatalogList<VideoManagementItem>> GetVideosAsync(
DirectContentActor actor,
@@ -96,4 +97,4 @@ public sealed partial class DirectContentService
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<QuestionVideoManagementItem>(ToQuestionVideoItem(item));
}
}
}

View File

@@ -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<CatalogList<VocabularyUnit>> GetVocabularyUnitsAsync(
DirectContentActor actor,
@@ -147,4 +148,4 @@ public sealed partial class DirectContentService
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<VocabularyWord>(item);
}
}
}

View File

@@ -30,7 +30,15 @@ internal static class ContentModule
services.AddScoped<ITaxonomyService, TaxonomyService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
services.AddScoped<IContentManagementService, ContentManagementService>();
services.AddScoped<IDirectContentService, DirectContentService>();
services.AddScoped<DirectContentServiceDependencies>();
services.AddScoped<IQuestionManagementService, QuestionManagementService>();
services.AddScoped<IVocabularyManagementService, VocabularyManagementService>();
services.AddScoped<IHandbookManagementService, HandbookManagementService>();
services.AddScoped<IEducationCatalogManagementService, EducationCatalogManagementService>();
services.AddScoped<IScorelineManagementService, ScorelineManagementService>();
services.AddScoped<IVideoManagementService, VideoManagementService>();
services.AddScoped<IOperationContentManagementService, OperationContentManagementService>();
services.AddScoped<IContentImportService, ContentImportService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IPublicQuestionAccessPolicy, PublicQuestionAccessPolicy>();
services.AddScoped<IQuestionReferenceService, QuestionReferenceService>();

View File

@@ -35,7 +35,6 @@ public sealed class ArchitectureBoundaryTests
var root = FindRepositoryRoot();
var legacyLineBudgets = new Dictionary<string, int>(StringComparer.Ordinal)
{
["DirectContentService"] = 2015,
["CommerceAdminService"] = 1842,
["LearningActivityService"] = 1781,
["PlatformAdminService"] = 1719,