Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/UsersExcelController.cs
T
biss 022e79c48d 已完成“组织与权限”优化:
基础数据拆分为:组织机构、学年学期、教学场所。
人员档案拆分为:教师档案、学生档案。
基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。
导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。
用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。
导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。
保留旧地址重定向,原有入口不会失效。
2026-07-24 18:12:56 +08:00

341 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);