基础数据拆分为:组织机构、学年学期、教学场所。 人员档案拆分为:教师档案、学生档案。 基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。 导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。 用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。 导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。 保留旧地址重定向,原有入口不会失效。
341 lines
15 KiB
C#
341 lines
15 KiB
C#
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);
|