新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12) 接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153) 缓存学生激活选项、基础数据、公开课表和课表选项。 个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。 基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。 新增 /health/cache,Redis 故障不影响 /health/ready。 Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。 更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
652 lines
28 KiB
C#
652 lines
28 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Common;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Excel;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize(Roles = Administrators)]
|
|
[Route("api/base-data")]
|
|
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
|
{
|
|
private const string Administrators =
|
|
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
|
|
|
private static readonly IReadOnlyDictionary<string, string[]> Headers =
|
|
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["campuses"] = ["编码", "名称", "地址", "排序", "状态"],
|
|
["colleges"] = ["编码", "名称", "简称", "所属校区编码", "排序", "状态"],
|
|
["majors"] = ["编码", "名称", "所属学院编码", "学位类型", "学制", "排序", "状态"],
|
|
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
|
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
|
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
|
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
|
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
|
};
|
|
|
|
[HttpGet("{kind}/template")]
|
|
public IActionResult DownloadTemplate(string kind)
|
|
{
|
|
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
|
var bytes = ExcelWorkbookHelper.Create(
|
|
"数据导入",
|
|
headers,
|
|
[],
|
|
[
|
|
"请勿修改第一行列名,不要合并单元格。",
|
|
"编码是唯一标识;编码已存在时更新,不存在时新增。",
|
|
"关联字段填写对应数据的编码,请先导入上级基础数据。",
|
|
"状态填写“启用”或“停用”;是/否字段填写“是”或“否”。",
|
|
"整批数据会先校验,任一行有误时均不会写入。"
|
|
]);
|
|
return File(bytes, ExcelWorkbookHelper.ContentType, $"{KindName(kind)}导入模板.xlsx");
|
|
}
|
|
|
|
[HttpGet("{kind}/export")]
|
|
public async Task<IActionResult> Export(string kind, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
|
var rows = await GetExportRowsAsync(kind, cancellationToken);
|
|
var bytes = ExcelWorkbookHelper.Create(KindName(kind), headers, rows);
|
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
|
$"{KindName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx");
|
|
}
|
|
|
|
[HttpPost("{kind}/import")]
|
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
|
public async Task<ActionResult<ExcelImportResult>> Import(
|
|
string kind,
|
|
IFormFile file,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
|
IReadOnlyList<ExcelRow> rows;
|
|
try
|
|
{
|
|
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
|
|
}
|
|
catch (InvalidDataException exception)
|
|
{
|
|
return ValidationProblem(exception.Message);
|
|
}
|
|
|
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
|
return await db.ExecuteInRetriableTransactionAsync<
|
|
ActionResult<ExcelImportResult>>(
|
|
async transaction =>
|
|
{
|
|
var errors = new List<string>();
|
|
ExcelImportResult result;
|
|
try
|
|
{
|
|
result = kind.ToLowerInvariant() switch
|
|
{
|
|
"campuses" => await ImportCampusesAsync(
|
|
rows, errors, cancellationToken),
|
|
"colleges" => await ImportCollegesAsync(
|
|
rows, errors, cancellationToken),
|
|
"majors" => await ImportMajorsAsync(
|
|
rows, errors, cancellationToken),
|
|
"classes" => await ImportClassesAsync(
|
|
rows, errors, cancellationToken),
|
|
"terms" => await ImportTermsAsync(
|
|
rows, errors, cancellationToken),
|
|
"buildings" => await ImportBuildingsAsync(
|
|
rows, errors, cancellationToken),
|
|
"classrooms" => await ImportClassroomsAsync(
|
|
rows, errors, cancellationToken),
|
|
"course-categories" => await ImportCourseCategoriesAsync(
|
|
rows, errors, cancellationToken),
|
|
_ => throw new InvalidOperationException()
|
|
};
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return ImportValidationProblem(errors);
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await cache.RemoveByTagAsync(
|
|
AppCacheTags.BaseData,
|
|
cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return Conflict(new ProblemDetails
|
|
{
|
|
Title = "导入失败",
|
|
Detail = "存在重复编码或无效关联,未写入任何数据。",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
|
|
string kind,
|
|
CancellationToken cancellationToken) =>
|
|
kind.ToLowerInvariant() switch
|
|
{
|
|
"campuses" => (await db.Campuses.AsNoTracking().OrderBy(x => x.Code)
|
|
.ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.Address, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
|
"colleges" => (await db.Colleges.AsNoTracking().Include(x => x.Campus)
|
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.ShortName, x.Campus?.Code, x.SortOrder,
|
|
Status(x.IsEnabled))).ToList(),
|
|
"majors" => (await db.Majors.AsNoTracking().Include(x => x.College)
|
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.College!.Code, x.DegreeType,
|
|
x.SchoolingYears, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
|
"classes" => (await db.AdministrativeClasses.AsNoTracking().Include(x => x.Major)
|
|
.Include(x => x.CounselorUser).OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.Major!.Code, x.Grade,
|
|
x.CounselorUser?.StaffNumber, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
|
"terms" => (await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate)
|
|
.ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.AcademicYear, SeasonName(x.Season),
|
|
x.StartDate, x.EndDate, YesNo(x.IsCurrent), Status(x.IsEnabled))).ToList(),
|
|
"buildings" => (await db.Buildings.AsNoTracking().Include(x => x.Campus)
|
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.Campus!.Code, x.SortOrder,
|
|
Status(x.IsEnabled))).ToList(),
|
|
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
|
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
|
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
|
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
|
.ToListAsync(cancellationToken))
|
|
.Select(x => Row(x.Code, x.Name, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
|
_ => []
|
|
};
|
|
|
|
private async Task<ExcelImportResult> ImportCampusesAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.Campuses.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
if (code is null || name is null) continue;
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new Campus { Code = code, Name = name };
|
|
db.Campuses.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.Address = Optional(row, "地址");
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportCollegesAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.Colleges.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var campuses = await db.Campuses.AsNoTracking().ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
if (code is null || name is null) continue;
|
|
Guid? campusId = null;
|
|
var campusCode = Optional(row, "所属校区编码");
|
|
if (campusCode is not null)
|
|
{
|
|
if (!campuses.TryGetValue(campusCode, out var campus))
|
|
errors.Add($"第 {row.RowNumber} 行:所属校区编码“{campusCode}”不存在。");
|
|
else campusId = campus.Id;
|
|
}
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new College { Code = code, Name = name };
|
|
db.Colleges.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.ShortName = Optional(row, "简称");
|
|
entity.CampusId = campusId;
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportMajorsAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.Majors.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
var collegeCode = Required(row, "所属学院编码", errors);
|
|
var degreeType = Required(row, "学位类型", errors);
|
|
var years = ParseInt(row, "学制", 1, 8, errors);
|
|
if (code is null || name is null || collegeCode is null ||
|
|
degreeType is null || years is null) continue;
|
|
if (!colleges.TryGetValue(collegeCode, out var college))
|
|
{
|
|
errors.Add($"第 {row.RowNumber} 行:所属学院编码“{collegeCode}”不存在。");
|
|
continue;
|
|
}
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new Major
|
|
{
|
|
Code = code,
|
|
Name = name,
|
|
CollegeId = college.Id,
|
|
DegreeType = degreeType
|
|
};
|
|
db.Majors.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.CollegeId = college.Id;
|
|
entity.DegreeType = degreeType;
|
|
entity.SchoolingYears = years.Value;
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportClassesAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.AdministrativeClasses.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var majors = await db.Majors.AsNoTracking().ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var counselors = await db.Users.AsNoTracking()
|
|
.Where(user =>
|
|
user.StaffNumber != null &&
|
|
user.IsEnabled &&
|
|
db.UserRoles.Any(userRole =>
|
|
userRole.UserId == user.Id &&
|
|
db.Roles.Any(role =>
|
|
role.Id == userRole.RoleId &&
|
|
role.Name == SystemRoles.Counselor)))
|
|
.ToDictionaryAsync(x => x.StaffNumber!, StringComparer.OrdinalIgnoreCase,
|
|
cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
var majorCode = Required(row, "所属专业编码", errors);
|
|
var grade = ParseInt(row, "年级", 2000, 2200, errors);
|
|
if (code is null || name is null || majorCode is null || grade is null) continue;
|
|
if (!majors.TryGetValue(majorCode, out var major))
|
|
{
|
|
errors.Add($"第 {row.RowNumber} 行:所属专业编码“{majorCode}”不存在。");
|
|
continue;
|
|
}
|
|
ApplicationUser? counselor = null;
|
|
var counselorNumber = Optional(row, "辅导员工号");
|
|
if (counselorNumber is not null &&
|
|
!counselors.TryGetValue(counselorNumber, out counselor))
|
|
errors.Add($"第 {row.RowNumber} 行:辅导员工号“{counselorNumber}”不存在或已停用。");
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new AdministrativeClass
|
|
{
|
|
Code = code,
|
|
Name = name,
|
|
MajorId = major.Id,
|
|
Grade = grade.Value
|
|
};
|
|
db.AdministrativeClasses.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.MajorId = major.Id;
|
|
entity.Grade = grade.Value;
|
|
entity.CounselorUserId = counselor?.Id;
|
|
entity.CounselorName = counselor?.DisplayName;
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportTermsAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.AcademicTerms.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
AcademicTerm? requestedCurrent = null;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
var academicYear = Required(row, "学年", errors);
|
|
var season = ParseSeason(row, errors);
|
|
var startDate = ParseDate(row, "开始日期", true, errors);
|
|
var endDate = ParseDate(row, "结束日期", true, errors);
|
|
if (code is null || name is null || academicYear is null || season is null ||
|
|
startDate is null || endDate is null) continue;
|
|
if (endDate <= startDate)
|
|
{
|
|
errors.Add($"第 {row.RowNumber} 行:结束日期必须晚于开始日期。");
|
|
continue;
|
|
}
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new AcademicTerm
|
|
{
|
|
Code = code,
|
|
Name = name,
|
|
AcademicYear = academicYear,
|
|
Season = season.Value,
|
|
StartDate = startDate.Value,
|
|
EndDate = endDate.Value
|
|
};
|
|
db.AcademicTerms.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
entity.Name = name;
|
|
entity.AcademicYear = academicYear;
|
|
entity.Season = season.Value;
|
|
entity.StartDate = startDate.Value;
|
|
entity.EndDate = endDate.Value;
|
|
entity.IsEnabled = ParseEnabled(row, errors);
|
|
entity.IsCurrent = ParseBoolean(row, "当前学期", false, errors);
|
|
if (entity.IsCurrent)
|
|
{
|
|
if (requestedCurrent is not null)
|
|
errors.Add($"第 {row.RowNumber} 行:同一次导入只能设置一个当前学期。");
|
|
requestedCurrent = entity;
|
|
}
|
|
}
|
|
if (requestedCurrent is not null)
|
|
{
|
|
foreach (var term in await db.AcademicTerms.Where(x => x.Id != requestedCurrent.Id)
|
|
.ToListAsync(cancellationToken))
|
|
term.IsCurrent = false;
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportBuildingsAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.Buildings.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var campuses = await db.Campuses.AsNoTracking().ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
var campusCode = Required(row, "所属校区编码", errors);
|
|
if (code is null || name is null || campusCode is null) continue;
|
|
if (!campuses.TryGetValue(campusCode, out var campus))
|
|
{
|
|
errors.Add($"第 {row.RowNumber} 行:所属校区编码“{campusCode}”不存在。");
|
|
continue;
|
|
}
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new Building { Code = code, Name = name, CampusId = campus.Id };
|
|
db.Buildings.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.CampusId = campus.Id;
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportClassroomsAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.Classrooms.ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var buildings = await db.Buildings.AsNoTracking().ToDictionaryAsync(x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase, cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
var buildingCode = Required(row, "所属教学楼编码", errors);
|
|
var capacity = ParseInt(row, "容量", 1, 1000, errors);
|
|
var roomType = Required(row, "教室类型", errors);
|
|
if (code is null || name is null || buildingCode is null ||
|
|
capacity is null || roomType is null) continue;
|
|
if (!buildings.TryGetValue(buildingCode, out var building))
|
|
{
|
|
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
|
|
continue;
|
|
}
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new Classroom
|
|
{
|
|
Code = code,
|
|
Name = name,
|
|
BuildingId = building.Id,
|
|
RoomType = roomType
|
|
};
|
|
db.Classrooms.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
entity.BuildingId = building.Id;
|
|
entity.Capacity = capacity.Value;
|
|
entity.RoomType = roomType;
|
|
entity.Equipment = Optional(row, "设备");
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private async Task<ExcelImportResult> ImportCourseCategoriesAsync(
|
|
IReadOnlyList<ExcelRow> rows,
|
|
List<string> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await db.CourseCategories.ToDictionaryAsync(
|
|
x => x.Code,
|
|
StringComparer.OrdinalIgnoreCase,
|
|
cancellationToken);
|
|
var created = 0;
|
|
var updated = 0;
|
|
foreach (var row in rows)
|
|
{
|
|
var code = Required(row, "编码", errors);
|
|
var name = Required(row, "名称", errors);
|
|
if (code is null || name is null) continue;
|
|
var entity = existing.GetValueOrDefault(code);
|
|
if (entity is null)
|
|
{
|
|
entity = new CourseCategory { Code = code, Name = name };
|
|
db.CourseCategories.Add(entity);
|
|
existing[code] = entity;
|
|
created++;
|
|
}
|
|
else updated++;
|
|
ApplyCatalog(entity, row, name, errors);
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
|
{
|
|
foreach (var error in errors.Take(50))
|
|
ModelState.AddModelError("file", error);
|
|
if (errors.Count > 50)
|
|
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
|
|
return ValidationProblem(ModelState);
|
|
}
|
|
|
|
private static void ApplyCatalog(
|
|
CatalogEntity entity,
|
|
ExcelRow row,
|
|
string name,
|
|
List<string> errors)
|
|
{
|
|
entity.Name = name;
|
|
entity.SortOrder = string.IsNullOrWhiteSpace(row["排序"])
|
|
? 0
|
|
: ParseInt(row, "排序", 0, int.MaxValue, errors) ?? 0;
|
|
entity.IsEnabled = ParseEnabled(row, errors);
|
|
}
|
|
|
|
private static string? Required(ExcelRow row, string header, List<string> errors)
|
|
{
|
|
var value = Optional(row, header);
|
|
if (value is not null) return value;
|
|
errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。");
|
|
return null;
|
|
}
|
|
|
|
private static string? Optional(ExcelRow row, string header) =>
|
|
string.IsNullOrWhiteSpace(row[header]) ? null : row[header].Trim();
|
|
|
|
private static int? ParseInt(
|
|
ExcelRow row, string header, int minimum, int maximum, List<string> errors)
|
|
{
|
|
if (int.TryParse(row[header], out var value) && value >= minimum && value <= maximum)
|
|
return value;
|
|
errors.Add($"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。");
|
|
return null;
|
|
}
|
|
|
|
private static DateOnly? ParseDate(
|
|
ExcelRow row, string header, bool required, List<string> errors)
|
|
{
|
|
var text = row[header];
|
|
if (string.IsNullOrWhiteSpace(text) && !required) return null;
|
|
if (DateOnly.TryParse(text, out var value)) return value;
|
|
errors.Add($"第 {row.RowNumber} 行:“{header}”必须是有效日期(如 2026-09-01)。");
|
|
return null;
|
|
}
|
|
|
|
private static bool ParseEnabled(ExcelRow row, List<string> errors)
|
|
{
|
|
var value = row["状态"];
|
|
if (value is "启用" or "是" or "1" ||
|
|
value.Equals("true", StringComparison.OrdinalIgnoreCase)) return true;
|
|
if (value is "停用" or "否" or "0" ||
|
|
value.Equals("false", StringComparison.OrdinalIgnoreCase)) return false;
|
|
errors.Add($"第 {row.RowNumber} 行:“状态”请填写“启用”或“停用”。");
|
|
return true;
|
|
}
|
|
|
|
private static bool ParseBoolean(
|
|
ExcelRow row, string header, bool defaultValue, List<string> errors)
|
|
{
|
|
var value = row[header];
|
|
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
|
if (value is "是" or "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
if (value is "否" or "0" || value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写“是”或“否”。");
|
|
return defaultValue;
|
|
}
|
|
|
|
private static TermSeason? ParseSeason(ExcelRow row, List<string> errors)
|
|
{
|
|
var value = row["学期季"];
|
|
if (value is "秋季" or "秋季学期" ||
|
|
value.Equals("Autumn", StringComparison.OrdinalIgnoreCase)) return TermSeason.Autumn;
|
|
if (value is "春季" or "春季学期" ||
|
|
value.Equals("Spring", StringComparison.OrdinalIgnoreCase)) return TermSeason.Spring;
|
|
if (value is "夏季" or "夏季学期" ||
|
|
value.Equals("Summer", StringComparison.OrdinalIgnoreCase)) return TermSeason.Summer;
|
|
errors.Add($"第 {row.RowNumber} 行:“学期季”请填写秋季、春季或夏季。");
|
|
return null;
|
|
}
|
|
|
|
private static bool TryGetHeaders(string kind, out string[] headers) =>
|
|
Headers.TryGetValue(kind, out headers!);
|
|
|
|
private static IReadOnlyList<object?> Row(params object?[] values) => values;
|
|
private static string Status(bool enabled) => enabled ? "启用" : "停用";
|
|
private static string YesNo(bool value) => value ? "是" : "否";
|
|
private static string SeasonName(TermSeason season) => season switch
|
|
{
|
|
TermSeason.Autumn => "秋季",
|
|
TermSeason.Spring => "春季",
|
|
_ => "夏季"
|
|
};
|
|
|
|
private static string KindName(string kind) => kind.ToLowerInvariant() switch
|
|
{
|
|
"campuses" => "校区",
|
|
"colleges" => "学院",
|
|
"majors" => "专业",
|
|
"classes" => "行政班",
|
|
"terms" => "学期",
|
|
"buildings" => "教学楼",
|
|
"classrooms" => "教室",
|
|
"course-categories" => "课程分类",
|
|
_ => "基础数据"
|
|
};
|
|
}
|