using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Identity; 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) : ControllerBase { private const string Administrators = $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; private static readonly IReadOnlyDictionary Headers = new Dictionary(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 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> Import( string kind, IFormFile file, CancellationToken cancellationToken) { if (!TryGetHeaders(kind, out var headers)) return NotFound(); IReadOnlyList 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>( async transaction => { var errors = new List(); 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); return Ok(result); } catch (DbUpdateException) { await transaction.RollbackAsync(cancellationToken); return Conflict(new ProblemDetails { Title = "导入失败", Detail = "存在重复编码或无效关联,未写入任何数据。", Status = StatusCodes.Status409Conflict }); } }, cancellationToken); } private async Task>> 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 ImportCampusesAsync( IReadOnlyList rows, List 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 ImportCollegesAsync( IReadOnlyList rows, List 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 ImportMajorsAsync( IReadOnlyList rows, List 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 ImportClassesAsync( IReadOnlyList rows, List 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 ImportTermsAsync( IReadOnlyList rows, List 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 ImportBuildingsAsync( IReadOnlyList rows, List 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 ImportClassroomsAsync( IReadOnlyList rows, List 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 ImportCourseCategoriesAsync( IReadOnlyList rows, List 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 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 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 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 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 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 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 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 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 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" => "课程分类", _ => "基础数据" }; }