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

基础数据拆分为:组织机构、学年学期、教学场所。
人员档案拆分为:教师档案、学生档案。
基础数据、教师、学生、用户权限均支持 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,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);