using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; 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 = ReadRoles)] [Route("api/personnel")] public sealed class PersonnelExcelController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, IAppCache cache) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor + "," + SystemRoles.Teacher + "," + SystemRoles.Student; private const string WriteRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; private static readonly string[] TeacherHeaders = [ "工号", "姓名", "性别", "学院编码", "职称", "任职状态", "入职日期", "教师类别", "联系电话", "电子邮箱", "备注" ]; private static readonly string[] StudentHeaders = [ "学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期", "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注" ]; [HttpGet("{kind}/template")] [Authorize(Roles = WriteRoles)] public IActionResult DownloadTemplate(string kind) { if (!TryGetHeaders(kind, out var headers)) return NotFound(); var bytes = ExcelWorkbookHelper.Create( "档案导入", headers, [], [ "请勿修改第一行列名;每行填写一位人员。", kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) ? "工号是唯一标识;学院编码必须已存在。" : "学号是唯一标识;行政班编码必须已存在。", "编号已存在时更新档案,不存在时新增档案。", "日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。", "整批数据会先校验,任一行有误时均不会写入。" ]); return File(bytes, ExcelWorkbookHelper.ContentType, $"{RegistryName(kind)}导入模板.xlsx"); } [HttpGet("{kind}/export")] public async Task Export( string kind, [FromQuery] PersonnelQuery query, CancellationToken cancellationToken) { if (!TryGetHeaders(kind, out var headers)) return NotFound(); IReadOnlyList> rows; if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)) { var source = ApplyTeacherFilters( ApplyTeacherScope(db.Teachers.AsNoTracking()), query); rows = (await source.Include(x => x.College).OrderBy(x => x.TeacherNumber) .ToListAsync(cancellationToken)) .Select(x => Row( x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code, x.Title, TeacherStatusName(x.Status), x.HireDate, x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes)) .ToList(); } else { var source = ApplyStudentFilters( ApplyStudentScope(db.Students.AsNoTracking()), query); rows = (await source.Include(x => x.AdministrativeClass) .OrderBy(x => x.StudentNumber).ToListAsync(cancellationToken)) .Select(x => Row( x.StudentNumber, x.Name, GenderName(x.Gender), x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate, StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes)) .ToList(); } var bytes = ExcelWorkbookHelper.Create(RegistryName(kind), headers, rows); return File(bytes, ExcelWorkbookHelper.ContentType, $"{RegistryName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); } [HttpPost("{kind}/import")] [Authorize(Roles = WriteRoles)] [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(); try { var result = kind.Equals( "teachers", StringComparison.OrdinalIgnoreCase) ? await ImportTeachersAsync(rows, errors, cancellationToken) : await ImportStudentsAsync(rows, errors, cancellationToken); if (errors.Count > 0) { await transaction.RollbackAsync(cancellationToken); return ImportValidationProblem(errors); } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); return Ok(result); } catch (DbUpdateException) { await transaction.RollbackAsync(cancellationToken); return Conflict(new ProblemDetails { Title = "导入失败", Detail = "存在重复编号或无效关联,未写入任何档案。", Status = StatusCodes.Status409Conflict }); } }, cancellationToken); } private async Task ImportTeachersAsync( IReadOnlyList rows, List errors, CancellationToken cancellationToken) { var existing = await db.Teachers.ToDictionaryAsync(x => x.TeacherNumber, 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 number = Required(row, "工号", errors); var name = Required(row, "姓名", errors); var collegeCode = Required(row, "学院编码", errors); var gender = ParseGender(row, errors); var status = ParseTeacherStatus(row, errors); var hireDate = ParseDate(row, "入职日期", false, errors); var isExternal = ParseTeacherType(row, errors); if (number is null || name is null || collegeCode is null || gender is null || status is null || isExternal is null) continue; if (!colleges.TryGetValue(collegeCode, out var college)) { errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。"); continue; } if (!CanAccessCollege(college.Id)) { errors.Add($"第 {row.RowNumber} 行:无权维护学院“{college.Name}”的教师。"); continue; } var entity = existing.GetValueOrDefault(number); if (entity is not null && !CanAccessCollege(entity.CollegeId)) { errors.Add($"第 {row.RowNumber} 行:工号“{number}”已属于无权维护的学院。"); continue; } if (entity is null) { entity = new Teacher { TeacherNumber = number, Name = name, CollegeId = college.Id }; db.Teachers.Add(entity); existing[number] = entity; created++; } else updated++; entity.Name = name; entity.Gender = gender.Value; entity.CollegeId = college.Id; entity.Title = Optional(row, "职称"); entity.Status = status.Value; entity.HireDate = hireDate; entity.IsExternal = isExternal.Value; entity.Phone = Optional(row, "联系电话"); entity.Email = Optional(row, "电子邮箱"); entity.Notes = Optional(row, "备注"); } return new(created, updated, rows.Count); } private async Task ImportStudentsAsync( IReadOnlyList rows, List errors, CancellationToken cancellationToken) { var existing = await db.Students.Include(x => x.AdministrativeClass) .ThenInclude(x => x!.Major) .ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken); var classes = await db.AdministrativeClasses.AsNoTracking().Include(x => x.Major) .ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); var created = 0; var updated = 0; foreach (var row in rows) { var number = Required(row, "学号", errors); var name = Required(row, "姓名", errors); var classCode = Required(row, "行政班编码", errors); var gender = ParseGender(row, errors); var enrollmentYear = ParseInt(row, "入学年级", 2000, 2200, errors); var enrollmentDate = ParseDate(row, "入学日期", true, errors); var status = ParseStudentStatus(row, errors); var dateOfBirth = ParseDate(row, "出生日期", false, errors); if (number is null || name is null || classCode is null || gender is null || enrollmentYear is null || enrollmentDate is null || status is null) continue; if (!classes.TryGetValue(classCode, out var targetClass)) { errors.Add($"第 {row.RowNumber} 行:行政班编码“{classCode}”不存在。"); continue; } var collegeId = targetClass.Major!.CollegeId; if (!CanAccessCollege(collegeId)) { errors.Add($"第 {row.RowNumber} 行:无权维护行政班“{targetClass.Name}”的学生。"); continue; } var entity = existing.GetValueOrDefault(number); if (entity is not null && !CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) { errors.Add($"第 {row.RowNumber} 行:学号“{number}”已属于无权维护的学院。"); continue; } if (entity is null) { entity = new Student { StudentNumber = number, Name = name, AdministrativeClassId = targetClass.Id, EnrollmentDate = enrollmentDate.Value }; db.Students.Add(entity); existing[number] = entity; created++; } else updated++; entity.Name = name; entity.Gender = gender.Value; entity.AdministrativeClassId = targetClass.Id; entity.EnrollmentYear = enrollmentYear.Value; entity.EnrollmentDate = enrollmentDate.Value; entity.Status = status.Value; entity.DateOfBirth = dateOfBirth; entity.Phone = Optional(row, "联系电话"); entity.Email = Optional(row, "电子邮箱"); entity.Notes = Optional(row, "备注"); } return new(created, updated, rows.Count); } private IQueryable ApplyTeacherFilters( IQueryable source, PersonnelQuery query) { if (query.CollegeId.HasValue) source = source.Where(x => x.CollegeId == query.CollegeId); if (query.TeacherStatus.HasValue) source = source.Where(x => x.Status == query.TeacherStatus); if (!string.IsNullOrWhiteSpace(query.Keyword)) { var keyword = query.Keyword.Trim(); source = source.Where(x => x.TeacherNumber.Contains(keyword) || x.Name.Contains(keyword) || (x.Title != null && x.Title.Contains(keyword))); } return source; } private IQueryable ApplyStudentFilters( IQueryable source, PersonnelQuery query) { if (query.CollegeId.HasValue) source = source.Where(x => x.AdministrativeClass!.Major!.CollegeId == query.CollegeId); if (query.MajorId.HasValue) source = source.Where(x => x.AdministrativeClass!.MajorId == query.MajorId); if (query.ClassId.HasValue) source = source.Where(x => x.AdministrativeClassId == query.ClassId); if (query.EnrollmentYear.HasValue) source = source.Where(x => x.EnrollmentYear == query.EnrollmentYear); if (query.StudentStatus.HasValue) source = source.Where(x => x.Status == query.StudentStatus); if (!string.IsNullOrWhiteSpace(query.Keyword)) { var keyword = query.Keyword.Trim(); source = source.Where(x => x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword)); } return source; } private IQueryable ApplyTeacherScope(IQueryable source) { var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); if (scope.Scope == DataScope.Class) return source.Where(teacher => db.TeachingTasks.Any(task => task.Teachers.Any(item => item.TeacherId == teacher.Id) && task.Classes.Any(item => item.AdministrativeClass!.CounselorUserId == scope.UserId))); var userId = scope.UserId; return scope.IsInRole(SystemRoles.Teacher) ? source.Where(x => x.UserId == userId) : source.Where(_ => false); } private IQueryable ApplyStudentScope(IQueryable source) { var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) return source.Where(x => x.AdministrativeClass!.Major!.CollegeId == scope.RestrictedCollegeId); if (scope.Scope == DataScope.Class) return source.Where(x => x.AdministrativeClass!.CounselorUserId == scope.UserId); var userId = scope.UserId; var isTeacher = scope.IsInRole(SystemRoles.Teacher); var isStudent = scope.IsInRole(SystemRoles.Student); return source.Where(student => isStudent && student.UserId == userId || isTeacher && db.TeachingTasks.Any(task => task.Teachers.Any(item => item.Teacher!.UserId == userId) && task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId))); } private bool CanAccessCollege(Guid collegeId) => currentUserDataScope.Current.CanAccessCollege(collegeId); 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 bool TryGetHeaders(string kind, out string[] headers) { if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)) { headers = TeacherHeaders; return true; } if (kind.Equals("students", StringComparison.OrdinalIgnoreCase)) { headers = StudentHeaders; return true; } headers = []; return false; } 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 value = row[header]; if (string.IsNullOrWhiteSpace(value) && !required) return null; if (DateOnly.TryParse(value, out var date)) return date; errors.Add($"第 {row.RowNumber} 行:“{header}”必须是有效日期(如 2026-09-01)。"); return null; } private static Gender? ParseGender(ExcelRow row, List errors) { var value = row["性别"]; if (string.IsNullOrWhiteSpace(value) || value is "未设置" or "未知" || value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) return Gender.Unknown; if (value == "男" || value.Equals("Male", StringComparison.OrdinalIgnoreCase)) return Gender.Male; if (value == "女" || value.Equals("Female", StringComparison.OrdinalIgnoreCase)) return Gender.Female; errors.Add($"第 {row.RowNumber} 行:“性别”请填写男、女或未设置。"); return null; } private static TeacherStatus? ParseTeacherStatus(ExcelRow row, List errors) { var value = row["任职状态"]; if (value == "在职" || value.Equals("Active", StringComparison.OrdinalIgnoreCase)) return TeacherStatus.Active; if (value == "离岗" || value.Equals("OnLeave", StringComparison.OrdinalIgnoreCase)) return TeacherStatus.OnLeave; if (value == "退休" || value.Equals("Retired", StringComparison.OrdinalIgnoreCase)) return TeacherStatus.Retired; if (value == "离职" || value.Equals("Departed", StringComparison.OrdinalIgnoreCase)) return TeacherStatus.Departed; errors.Add($"第 {row.RowNumber} 行:“任职状态”请填写在职、离岗、退休或离职。"); return null; } private static StudentStatus? ParseStudentStatus(ExcelRow row, List errors) { var value = row["学籍状态"]; if (value == "在籍" || value.Equals("Active", StringComparison.OrdinalIgnoreCase)) return StudentStatus.Active; if (value == "休学" || value.Equals("Suspended", StringComparison.OrdinalIgnoreCase)) return StudentStatus.Suspended; if (value == "毕业" || value.Equals("Graduated", StringComparison.OrdinalIgnoreCase)) return StudentStatus.Graduated; if (value == "退学" || value.Equals("Withdrawn", StringComparison.OrdinalIgnoreCase)) return StudentStatus.Withdrawn; errors.Add($"第 {row.RowNumber} 行:“学籍状态”请填写在籍、休学、毕业或退学。"); return null; } private static bool? ParseTeacherType(ExcelRow row, List errors) { var value = row["教师类别"]; if (value == "校内" || value == "否" || value == "0") return false; if (value == "外聘" || value == "是" || value == "1") return true; errors.Add($"第 {row.RowNumber} 行:“教师类别”请填写校内或外聘。"); return null; } private static IReadOnlyList Row(params object?[] values) => values; private static string RegistryName(string kind) => kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) ? "教师档案" : "学生档案"; private static string GenderName(Gender value) => value switch { Gender.Male => "男", Gender.Female => "女", _ => "未设置" }; private static string TeacherStatusName(TeacherStatus value) => value switch { TeacherStatus.Active => "在职", TeacherStatus.OnLeave => "离岗", TeacherStatus.Retired => "退休", _ => "离职" }; private static string StudentStatusName(StudentStatus value) => value switch { StudentStatus.Active => "在籍", StudentStatus.Suspended => "休学", StudentStatus.Graduated => "毕业", _ => "退学" }; }