已完成“组织与权限”优化:

基础数据拆分为:组织机构、学年学期、教学场所。
人员档案拆分为:教师档案、学生档案。
基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。
导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。
用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。
导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。
保留旧地址重定向,原有入口不会失效。
This commit is contained in:
2026-07-24 18:12:56 +08:00 Unverified
parent 662aa1051f
commit 022e79c48d
13 changed files with 2133 additions and 59 deletions
@@ -0,0 +1,587 @@
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<string, string[]> Headers =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["campuses"] = ["编码", "名称", "地址", "排序", "状态"],
["colleges"] = ["编码", "名称", "简称", "所属校区编码", "排序", "状态"],
["majors"] = ["编码", "名称", "所属学院编码", "学位类型", "学制", "排序", "状态"],
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"]
};
[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 中没有可导入的数据。");
var errors = new List<string>();
ExcelImportResult result;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
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),
_ => 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
});
}
}
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(),
_ => []
};
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 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" => "教室",
_ => "基础数据"
};
}
@@ -0,0 +1,503 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
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) : 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 中没有可导入的数据。");
var errors = new List<string>();
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
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);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
}
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 => "毕业",
_ => "退学"
};
}
@@ -0,0 +1,340 @@
using System.Security.Claims;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin)]
[Route("api/users")]
public sealed class UsersExcelController(
AppDbContext db,
UserManager<ApplicationUser> userManager) : ControllerBase
{
private static readonly string[] Headers =
[
"登录账号", "姓名", "初始密码", "工号/学号", "学院编码", "角色", "状态"
];
[HttpGet("template")]
public IActionResult DownloadTemplate()
{
var bytes = ExcelWorkbookHelper.Create(
"账号导入",
Headers,
[],
[
"登录账号是唯一标识;已存在时更新账号,不存在时创建账号。",
$"角色可填写:{string.Join("、", SystemRoles.All)};多个角色用顿号或逗号分隔。",
"新账号必须填写不少于 8 位的初始密码;更新账号时密码留空表示不修改。",
"教师或学生角色必须填写已存在且未关联其他账号的工号/学号。",
"状态填写“启用”或“停用”;整批校验失败时不会写入。"
]);
return File(bytes, ExcelWorkbookHelper.ContentType, "用户与权限导入模板.xlsx");
}
[HttpGet("export")]
public async Task<IActionResult> Export(CancellationToken cancellationToken)
{
var colleges = await db.Colleges.AsNoTracking()
.ToDictionaryAsync(x => x.Id, x => x.Code, cancellationToken);
var users = await userManager.Users.AsNoTracking()
.OrderBy(x => x.UserName).ToListAsync(cancellationToken);
var rows = new List<IReadOnlyList<object?>>();
foreach (var user in users)
{
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
var roles = identityUser is null
? []
: await userManager.GetRolesAsync(identityUser);
rows.Add(Row(
user.UserName, user.DisplayName, string.Empty, user.StaffNumber,
user.CollegeId.HasValue
? colleges.GetValueOrDefault(user.CollegeId.Value)
: null,
string.Join("、", roles),
user.IsEnabled ? "启用" : "停用"));
}
var bytes = ExcelWorkbookHelper.Create("用户与权限", Headers, rows);
return File(bytes, ExcelWorkbookHelper.ContentType,
$"用户与权限-{DateTime.Now:yyyyMMdd-HHmm}.xlsx");
}
[HttpPost("import")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult<ExcelImportResult>> Import(
IFormFile file,
CancellationToken cancellationToken)
{
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 中没有可导入的账号。");
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var users = await userManager.Users.ToDictionaryAsync(
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var userName = Required(row, "登录账号", errors);
var displayName = Required(row, "姓名", errors);
if (userName is null || displayName is null) continue;
if (!seenNames.Add(userName))
{
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
continue;
}
var existing = users.GetValueOrDefault(userName);
var password = Optional(row, "初始密码");
if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue && student.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var created = 0;
var updated = 0;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var plan in plans)
{
var user = plan.Existing;
if (user is null)
{
user = new ApplicationUser
{
UserName = plan.UserName,
DisplayName = plan.DisplayName,
StaffNumber = plan.StaffNumber,
CollegeId = plan.CollegeId,
IsEnabled = plan.IsEnabled,
LockoutEnabled = true
};
var createResult = await userManager.CreateAsync(user, plan.Password!);
if (!createResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(createResult, plan.RowNumber);
}
created++;
}
else
{
user.DisplayName = plan.DisplayName;
user.StaffNumber = plan.StaffNumber;
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(updateResult, plan.RowNumber);
}
if (plan.Password is not null)
{
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var passwordResult = await userManager.ResetPasswordAsync(
user, token, plan.Password);
if (!passwordResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(passwordResult, plan.RowNumber);
}
}
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(removeResult, plan.RowNumber);
}
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return Ok(new ExcelImportResult(created, updated, plans.Count));
}
[HttpPut("{id:guid}/password")]
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request)
{
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var result = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
return result.Succeeded ? NoContent() : IdentityValidationProblem(result);
}
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 ActionResult IdentityValidationProblem(
IdentityResult result,
int? rowNumber = null)
{
foreach (var error in result.Errors)
ModelState.AddModelError(
"identity",
rowNumber.HasValue
? $"第 {rowNumber} 行:{error.Description}"
: error.Description);
return ValidationProblem(ModelState);
}
private static string[] ParseRoles(ExcelRow row, List<string> errors)
{
var roles = row["角色"]
.Split([',', '', ';', '', '、'], StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (roles.Length == 0)
errors.Add($"第 {row.RowNumber} 行:“角色”至少填写一个角色。");
var invalid = roles.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase).ToArray();
if (invalid.Length > 0)
errors.Add($"第 {row.RowNumber} 行:无效角色“{string.Join("", invalid)}”。");
return roles;
}
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 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 IReadOnlyList<object?> Row(params object?[] values) => values;
private sealed record UserImportPlan(
int RowNumber,
ApplicationUser? Existing,
string UserName,
string DisplayName,
string? Password,
string? StaffNumber,
Guid? CollegeId,
string[] Roles,
bool IsEnabled,
Teacher? Teacher,
Student? Student);
}
public sealed record ResetUserPasswordRequest(
[System.ComponentModel.DataAnnotations.Required,
System.ComponentModel.DataAnnotations.MinLength(8),
System.ComponentModel.DataAnnotations.MaxLength(100)]
string NewPassword);