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 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 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>(); 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> Import( IFormFile file, CancellationToken cancellationToken) { IReadOnlyList 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(); var plans = new List(); var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); var plannedTeachers = new HashSet(StringComparer.OrdinalIgnoreCase); var plannedStudents = new HashSet(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 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 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 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 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 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 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);