Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs
T
biss 77dfa8145c 主要变更:
新增统一缓存封装:[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) 部署说明。
2026-07-26 14:42:27 +08:00

516 lines
22 KiB
C#

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<IActionResult> Export(
string kind,
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
if (!TryGetHeaders(kind, out var headers)) return NotFound();
IReadOnlyList<IReadOnlyList<object?>> 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<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>();
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<ExcelImportResult> ImportTeachersAsync(
IReadOnlyList<ExcelRow> rows,
List<string> 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<ExcelImportResult> ImportStudentsAsync(
IReadOnlyList<ExcelRow> rows,
List<string> 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<Teacher> ApplyTeacherFilters(
IQueryable<Teacher> 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<Student> ApplyStudentFilters(
IQueryable<Student> 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<Teacher> ApplyTeacherScope(IQueryable<Teacher> 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<Student> ApplyStudentScope(IQueryable<Student> 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<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 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<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 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<string> 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<string> 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<string> 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<string> 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<object?> 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 => "毕业",
_ => "退学"
};
}