Files
tiku-backend.net/Tiku.Infrastructure/Content/Foundation/ContentManagementFoundation.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

501 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Linq.Expressions;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
internal abstract partial class ContentManagementServiceBase
{
protected static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
new Dictionary<string, ImportSpec>(StringComparer.Ordinal)
{
["questions"] = new(
"questions",
"题目导入模板",
"用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。",
[
Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。",
"tj-english-2026-001"),
Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。",
"choice"),
Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。",
"多租户 SaaS 最重要的安全边界是什么?"),
Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }),
Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始CSV 可用 A/B/C/D。",
new[] { 1 }),
Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"),
Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"),
Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2),
Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" })
],
[
["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"],
[
"tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。",
"2", "安全|多租户"
]
],
new
{
items = new[]
{
new
{
legacyId = "tj-english-2026-001",
type = "choice",
content = "多租户 SaaS 最重要的安全边界是什么?",
options = new[] { "前端隐藏", "后端权限" },
correctOptionIndices = new[] { 1 },
explanation = "最终权限以后端强制为准。",
difficulty = 2,
tags = new[] { "安全", "多租户" }
}
}
}),
["vocabulary"] = new(
"vocabulary",
"单词导入模板",
"用于导入词汇单元和单词,后端会按单元归组并幂等写入。",
[
Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"),
Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"),
Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"),
Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"),
Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.")
],
[
["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"],
["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"]
],
new
{
units = new[]
{ new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } }
}),
["handbook"] = new(
"handbook",
"知识手册导入模板",
"用于导入手册科目、章节、小节和知识点。",
[
Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"),
Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"),
Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"),
Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。")
],
[
["subjectName", "chapterName", "title", "content", "tags"],
["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"]
],
new
{
subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } }
}),
["scoreline"] = new(
"scoreline",
"分数线导入模板",
"用于导入动态字段、院校、专业和年份分数线记录。",
[
Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"),
Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"),
Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"),
Field("year", "年份", false, ["年份"], "record 常用。", 2026),
Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 })
],
[
["kind", "schoolName", "majorName", "year", "minScore"],
["record", "天津职业技术师范大学", "软件工程", "2026", "188"]
],
new
{
records = new[]
{
new
{
schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026,
fieldValues = new { minScore = 188 }
}
}
}),
["videos"] = new(
"videos",
"视频解析导入模板",
"用于导入视频解析元数据并绑定到题目。",
[
Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"),
Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。",
"https://cdn.example.test/video.mp4"),
Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。",
"00000000-0000-0000-0000-000000000000"),
Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。",
"tj-english-2026-001")
],
[
["title", "videoUrl", "legacyQuestionId", "videoType"],
["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"]
],
new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } })
};
protected async Task<(string Path, int Depth)> BuildNodePathAsync(
Guid tenantId,
Guid entryId,
Guid nodeId,
Guid? parentId,
CancellationToken cancellationToken)
{
var label = $"n_{nodeId:N}";
if (!parentId.HasValue) return (label, 0);
var parent = await questionBankPersistence.ContentNodes
.AsNoTracking()
.Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value)
.Select(node => new { node.Path, node.Depth })
.SingleOrDefaultAsync(cancellationToken);
if (parent is null)
throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found");
return ($"{parent.Path}.{label}", parent.Depth + 1);
}
protected async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(tenantId, regionId, "region_not_found", cancellationToken);
}
protected async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
}
protected async Task AssertEntryAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? entryId,
CancellationToken cancellationToken)
{
if (!entryId.HasValue) return;
var regionIds = scope.RegionIds.ToArray();
var exists = await questionBankPersistence.ContentEntries
.Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists) throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
protected async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
}
protected async Task AssertNodeAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? nodeId,
CancellationToken cancellationToken)
{
if (!nodeId.HasValue) return;
var regionIds = scope.RegionIds.ToArray();
var exists = await questionBankPersistence.ContentNodes
.Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists) throw new ContentManagementException("Content node was not found.", "node_not_found");
}
protected async Task<CurrentDataScope> RequireDataScopeAsync(
ContentManagementActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
throw new ContentManagementException("Content resource was not found.", "content_not_found");
return access.DataScope;
}
protected async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : class
{
if (!id.HasValue) return;
var exists = await questionBankPersistence.Set<TEntity>()
.AnyAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
cancellationToken);
if (!exists) throw new ContentManagementException("Referenced entity was not found in this tenant.", code);
}
protected static async Task<TEntity?> ResolveEntityAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
Expression<Func<TEntity, bool>> alternatePredicate,
CancellationToken cancellationToken)
where TEntity : class
{
if (id.HasValue)
{
var byId = await set.SingleOrDefaultAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
cancellationToken);
if (byId is not null) return byId;
}
return await set
.Where(entity => EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId)
.SingleOrDefaultAsync(alternatePredicate, cancellationToken);
}
protected static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
string? legacyId,
CancellationToken cancellationToken)
where TEntity : class
{
if (id.HasValue)
{
var byId = await set.SingleOrDefaultAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
cancellationToken);
if (byId is not null) return byId;
}
var normalizedLegacyId = Normalize(legacyId);
if (normalizedLegacyId is null) return null;
return await set.SingleOrDefaultAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<string?>(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId,
cancellationToken);
}
protected static ContentEntryManagementItem ToEntryItem(ContentEntry entry)
{
return new ContentEntryManagementItem(
entry.Id,
entry.RegionId,
entry.LegacyId,
entry.EntryKey,
entry.Name,
entry.EntryType,
entry.Icon,
entry.Route,
entry.Description,
entry.Visibility,
entry.AccessRules,
entry.LayoutConfig,
entry.SortOrder,
entry.IsActive,
entry.CreatedAt,
entry.UpdatedAt);
}
protected static ContentNodeManagementItem ToNodeItem(ContentNode node)
{
return new ContentNodeManagementItem(
node.Id,
node.EntryId,
node.RegionId,
node.ParentId,
node.LegacyId,
node.NodeKey,
node.Name,
node.NodeType,
node.MarkerType,
node.MarkerConfig,
node.Path,
node.Depth,
node.SortOrder,
node.IsActive,
node.IsSelectable,
node.IsLeaf,
node.AccessRules,
node.Metadata,
node.CreatedAt,
node.UpdatedAt);
}
protected static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection)
{
return new QuestionCollectionManagementItem(
collection.Id,
collection.RegionId,
collection.EntryId,
collection.NodeId,
collection.SubjectId,
collection.CategoryId,
collection.QuestionBankId,
collection.LegacyId,
collection.Name,
collection.CollectionType,
collection.SourceType,
collection.Filters,
collection.QuestionCount,
collection.TotalScore,
collection.DurationMinutes,
collection.Status,
collection.SortOrder,
collection.AccessRules,
collection.Metadata,
collection.CreatedAt,
collection.UpdatedAt);
}
protected static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item)
{
return new QuestionCollectionItemManagementItem(
item.Id,
item.CollectionId,
item.QuestionId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.SectionKey,
item.SortOrder,
item.Score,
item.Required,
item.Metadata);
}
protected static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint)
{
return new PracticeBlueprintManagementItem(
blueprint.Id,
blueprint.RegionId,
blueprint.EntryId,
blueprint.NodeId,
blueprint.CollectionId,
blueprint.LegacyId,
blueprint.Name,
blueprint.Mode,
blueprint.AssemblyType,
blueprint.QuestionLimit,
blueprint.DurationMinutes,
blueprint.TotalScore,
blueprint.PassScore,
blueprint.Sections,
blueprint.Rules,
blueprint.AccessRules,
blueprint.Status,
blueprint.SortOrder,
blueprint.CreatedAt,
blueprint.UpdatedAt);
}
protected static int ResolveLimit(int? limit)
{
return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit;
}
protected static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
protected static JsonElement JsonObjectOrDefault(JsonElement value)
{
return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
? JsonDefaults.Object()
: value;
}
protected static JsonElement JsonArrayOrDefault(JsonElement value)
{
return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
? JsonDefaults.Array()
: value;
}
protected static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value)) return fallback;
if (Enum.TryParse<TEnum>(value, true, out var parsed)) return parsed;
throw new ContentManagementException("Invalid enum value.", code);
}
protected static TEnum? ParseNullable<TEnum>(string? value, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (Enum.TryParse<TEnum>(value, true, out var parsed)) return parsed;
throw new ContentManagementException("Invalid enum value.", code);
}
protected static bool TryParse<TEnum>(string? value, out TEnum parsed)
where TEnum : struct
{
return Enum.TryParse(value, true, out parsed);
}
protected static string EscapeCsv(string value)
{
return value.Contains(',') || value.Contains('"') || value.Contains('\n')
? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
: value;
}
protected static ImportSpec ResolveImportSpec(string importType)
{
var normalized = importType.Trim().ToLowerInvariant();
return Specs.TryGetValue(normalized, out var spec)
? spec
: throw new ContentManagementException("Import type is not supported.", "import_type_invalid");
}
protected static ImportFieldSpec Field(
string field,
string label,
bool required,
string[] aliases,
string description,
object example)
{
return new ImportFieldSpec(
field,
label,
required,
aliases,
description,
JsonSerializer.SerializeToElement(example));
}
protected sealed record ImportSpec(
string ImportType,
string Title,
string Description,
IReadOnlyCollection<ImportFieldSpec> Fields,
string[][] CsvRows,
object JsonExample);
}