501 lines
21 KiB
C#
501 lines
21 KiB
C#
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);
|
||
}
|