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