using System.ComponentModel.DataAnnotations; using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; 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 UsersController( AppDbContext db, UserManager userManager, RoleManager roleManager) : ControllerBase { [HttpGet] public async Task>> GetUsers( int page = 1, int pageSize = 20, string? keyword = null, CancellationToken cancellationToken = default) { if (page < 1 || pageSize is < 1 or > 100) return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); var query = userManager.Users.AsNoTracking(); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); query = query.Where(user => user.UserName!.Contains(keyword) || user.DisplayName.Contains(keyword) || (user.StaffNumber != null && user.StaffNumber.Contains(keyword)) || (from userRole in db.UserRoles join role in db.Roles on userRole.RoleId equals role.Id where userRole.UserId == user.Id && role.Name!.Contains(keyword) select role.Id).Any()); } var total = await query.CountAsync(cancellationToken); var users = await query .OrderBy(x => x.UserName) .ThenBy(x => x.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.UserName, x.DisplayName, x.StaffNumber, x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt }) .ToListAsync(cancellationToken); var userIds = users.Select(x => x.Id).ToArray(); var roleRows = await ( from userRole in db.UserRoles.AsNoTracking() join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id where userIds.Contains(userRole.UserId) select new { userRole.UserId, RoleName = role.Name! }) .ToListAsync(cancellationToken); var rolesByUser = roleRows .GroupBy(x => x.UserId) .ToDictionary( group => group.Key, group => (IReadOnlyCollection)group .Select(x => x.RoleName) .OrderBy(x => x) .ToArray()); var items = users.Select(user => new UserListItem( user.Id, user.UserName ?? string.Empty, user.DisplayName, user.StaffNumber, user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt, rolesByUser.GetValueOrDefault(user.Id, Array.Empty()))) .ToArray(); return Ok(new PagedResult(items, total, page, pageSize)); } [HttpGet("roles")] public async Task> GetRoles(CancellationToken cancellationToken) => Ok(await roleManager.Roles.AsNoTracking() .OrderBy(x => x.Name) .Select(x => new { x.Name, x.Description, x.DataScope }) .ToListAsync(cancellationToken)); [HttpPost] public async Task Create( CreateUserRequest request, CancellationToken cancellationToken) { var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var invalidRoles = ValidateRoles(roles); if (invalidRoles is not null) return invalidRoles; var staffNumber = Normalize(request.StaffNumber); return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var profiles = await ResolveProfilesAsync(staffNumber, roles); if (profiles.Error is not null) return profiles.Error; var user = new ApplicationUser { UserName = request.UserName.Trim(), DisplayName = request.DisplayName.Trim(), StaffNumber = staffNumber, CollegeId = request.CollegeId, LockoutEnabled = true, IsEnabled = true }; var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded) return IdentityValidationProblem(result); result = await userManager.AddToRolesAsync(user, roles); if (!result.Succeeded) return IdentityValidationProblem(result); if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id; if (profiles.Student is not null) profiles.Student.UserId = user.Id; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return CreatedAtAction( nameof(GetUsers), new { id = user.Id }, new { user.Id }); }, cancellationToken); } [HttpPut("{id:guid}/status")] public async Task SetStatus(Guid id, SetUserStatusRequest request) { var user = await userManager.FindByIdAsync(id.ToString()); if (user is null) return NotFound(); if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && !request.IsEnabled) return ValidationProblem("不能停用当前登录账号。"); user.IsEnabled = request.IsEnabled; var result = await userManager.UpdateAsync(user); return result.Succeeded ? NoContent() : IdentityValidationProblem(result); } [HttpPut("{id:guid}/roles")] public async Task SetRoles( Guid id, SetRolesRequest request, CancellationToken cancellationToken) { var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var invalidRoles = ValidateRoles(roles); if (invalidRoles is not null) return invalidRoles; var staffNumber = Normalize(request.StaffNumber); return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var user = await userManager.FindByIdAsync(id.ToString()); if (user is null) return NotFound(); var profiles = await ResolveProfilesAsync( staffNumber, roles, user.Id); if (profiles.Error is not null) return profiles.Error; var existing = await userManager.GetRolesAsync(user); if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && existing.Contains(SystemRoles.SuperAdmin) && !roles.Contains( SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) { return ValidationProblem( "不能移除当前账号的超级管理员角色。"); } user.StaffNumber = staffNumber; user.CollegeId = request.CollegeId; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult); var removeResult = await userManager.RemoveFromRolesAsync( user, existing.Except(roles, StringComparer.OrdinalIgnoreCase)); if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult); var addResult = await userManager.AddToRolesAsync( user, roles.Except(existing, StringComparer.OrdinalIgnoreCase)); if (!addResult.Succeeded) return IdentityValidationProblem(addResult); var linkedTeachers = await db.Teachers .Where(x => x.UserId == id) .ToListAsync(); var linkedStudents = await db.Students .Where(x => x.UserId == id) .ToListAsync(); if (!roles.Contains( SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) foreach (var teacher in linkedTeachers) teacher.UserId = null; if (!roles.Contains( SystemRoles.Student, StringComparer.OrdinalIgnoreCase)) foreach (var student in linkedStudents) student.UserId = null; if (profiles.Teacher is not null) profiles.Teacher.UserId = id; if (profiles.Student is not null) profiles.Student.UserId = id; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return NoContent(); }, cancellationToken); } private ActionResult? ValidateRoles(IReadOnlyCollection roles) { if (roles.Count == 0) return ValidationProblem("至少需要分配一个角色。"); var invalidRoles = roles .Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase) .ToArray(); return invalidRoles.Length == 0 ? null : ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}"); } private async Task<(Teacher? Teacher, Student? Student, ActionResult? Error)> ResolveProfilesAsync( string? staffNumber, IReadOnlyCollection roles, Guid? currentUserId = null) { var needsTeacher = roles.Contains( SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase); var needsStudent = roles.Contains( SystemRoles.Student, StringComparer.OrdinalIgnoreCase); if (!needsTeacher && !needsStudent) return (null, null, null); if (string.IsNullOrWhiteSpace(staffNumber)) return (null, null, ValidationProblem("教师或学生角色必须填写对应的工号/学号。")); Teacher? teacher = null; Student? student = null; if (needsTeacher) { teacher = await db.Teachers.SingleOrDefaultAsync( x => x.TeacherNumber == staffNumber); if (teacher is null) return (null, null, ValidationProblem("未找到与该工号对应的教师档案。")); if (teacher.UserId.HasValue && teacher.UserId != currentUserId) return (null, null, ConflictProblem("该教师档案已经关联其他账号。")); } if (needsStudent) { student = await db.Students.SingleOrDefaultAsync( x => x.StudentNumber == staffNumber); if (student is null) return (null, null, ValidationProblem("未找到与该学号对应的学生档案。")); if (student.UserId.HasValue && student.UserId != currentUserId) return (null, null, ConflictProblem("该学生档案已经关联其他账号。")); } return (teacher, student, null); } private ActionResult IdentityValidationProblem(IdentityResult result) { foreach (var error in result.Errors) ModelState.AddModelError(error.Code, error.Description); return ValidationProblem(ModelState); } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "账号关联冲突", Detail = detail, Status = StatusCodes.Status409Conflict }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public sealed record CreateUserRequest( [Required, MaxLength(50)] string UserName, [Required, MaxLength(50)] string DisplayName, [Required, MinLength(8), MaxLength(100)] string Password, [MaxLength(30)] string? StaffNumber, Guid? CollegeId, [MinLength(1)] string[] Roles); public sealed record SetUserStatusRequest(bool IsEnabled); public sealed record UserListItem( Guid Id, string UserName, string DisplayName, string? StaffNumber, Guid? CollegeId, bool IsEnabled, DateTime? LastLoginAt, DateTime CreatedAt, IReadOnlyCollection Roles); public sealed record SetRolesRequest( [MaxLength(30)] string? StaffNumber, Guid? CollegeId, [MinLength(1)] string[] Roles);