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; using MySql.Data.MySqlClient; 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, string? roleName = null, Guid? collegeId = null, bool? isEnabled = null, bool? hasLoggedIn = null, CancellationToken cancellationToken = default) { if (page < 1 || pageSize is < 1 or > 100) return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); // Connector/NET 10.0.9 currently has a provider-side command creation // failure with this Identity query on EF Core 10.0.11. Keep the normal // LINQ path for SQLite/tests and bypass only this affected MySQL read. if (db.Database.ProviderName?.Contains("MySql", StringComparison.OrdinalIgnoreCase) == true) return Ok(await GetUsersFromMySqlAsync( page, pageSize, keyword, roleName, collegeId, isEnabled, hasLoggedIn, cancellationToken)); 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()); } if (!string.IsNullOrWhiteSpace(roleName)) { roleName = roleName.Trim(); query = query.Where(user => (from userRole in db.UserRoles join candidateRole in db.Roles on userRole.RoleId equals candidateRole.Id where userRole.UserId == user.Id && candidateRole.Name == roleName select userRole.RoleId).Any()); } if (collegeId.HasValue) query = query.Where(user => user.CollegeId == collegeId.Value); if (isEnabled.HasValue) query = query.Where(user => user.IsEnabled == isEnabled.Value); if (hasLoggedIn.HasValue) query = hasLoggedIn.Value ? query.Where(user => user.LastLoginAt != null) : query.Where(user => user.LastLoginAt == null); 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)); } private async Task> GetUsersFromMySqlAsync( int page, int pageSize, string? keyword, string? roleName, Guid? collegeId, bool? isEnabled, bool? hasLoggedIn, CancellationToken cancellationToken) { var clauses = new List(); if (!string.IsNullOrWhiteSpace(keyword)) clauses.Add("(" + "u.`UserName` LIKE CONCAT('%', @keyword, '%') OR " + "u.`DisplayName` LIKE CONCAT('%', @keyword, '%') OR " + "u.`StaffNumber` LIKE CONCAT('%', @keyword, '%') OR " + "EXISTS (SELECT 1 FROM `AspNetUserRoles` ur " + "JOIN `AspNetRoles` r ON ur.`RoleId` = r.`Id` " + "WHERE ur.`UserId` = u.`Id` AND r.`Name` LIKE CONCAT('%', @keyword, '%')))" ); if (!string.IsNullOrWhiteSpace(roleName)) clauses.Add("EXISTS (SELECT 1 FROM `AspNetUserRoles` ur " + "JOIN `AspNetRoles` r ON ur.`RoleId` = r.`Id` " + "WHERE ur.`UserId` = u.`Id` AND r.`Name` = @roleName)"); if (collegeId.HasValue) clauses.Add("u.`CollegeId` = @collegeId"); if (isEnabled.HasValue) clauses.Add("u.`IsEnabled` = @isEnabled"); if (hasLoggedIn.HasValue) clauses.Add(hasLoggedIn.Value ? "u.`LastLoginAt` IS NOT NULL" : "u.`LastLoginAt` IS NULL"); var where = clauses.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", clauses); var connection = (MySqlConnection)db.Database.GetDbConnection(); await connection.OpenAsync(cancellationToken); try { await using var countCommand = CreateCommand( connection, "SELECT COUNT(*) FROM `AspNetUsers` u" + where, keyword, roleName, collegeId, isEnabled); var total = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken)); await using var usersCommand = CreateCommand(connection, "SELECT u.`Id`, u.`UserName`, u.`DisplayName`, u.`StaffNumber`, u.`CollegeId`, " + "u.`IsEnabled`, u.`LastLoginAt`, u.`CreatedAt` FROM `AspNetUsers` u" + where + " ORDER BY u.`UserName`, u.`Id` LIMIT @take OFFSET @skip", keyword, roleName, collegeId, isEnabled); usersCommand.Parameters.AddWithValue("@take", pageSize); usersCommand.Parameters.AddWithValue("@skip", (page - 1) * pageSize); var users = new List(); await using (var reader = await usersCommand.ExecuteReaderAsync(cancellationToken)) { while (await reader.ReadAsync(cancellationToken)) { var id = Guid.Parse(reader.GetString(0)); users.Add(new UserListItem(id, reader.GetString(1), reader.GetString(2), reader.IsDBNull(3) ? null : reader.GetString(3), reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), Convert.ToBoolean(reader.GetValue(5)), reader.IsDBNull(6) ? null : reader.GetDateTime(6), reader.GetDateTime(7), Array.Empty())); } } var rolesByUser = new Dictionary>(); if (users.Count > 0) { await using var rolesCommand = new MySqlCommand( "SELECT ur.`UserId`, r.`Name` FROM `AspNetUserRoles` ur " + "JOIN `AspNetRoles` r ON ur.`RoleId` = r.`Id` WHERE ur.`UserId` IN (" + string.Join(", ", users.Select((_, index) => "@user" + index)) + ")", connection); foreach (var (user, index) in users.Select((user, index) => (user, index))) rolesCommand.Parameters.AddWithValue("@user" + index, user.Id.ToString()); await using var roleReader = await rolesCommand.ExecuteReaderAsync(cancellationToken); while (await roleReader.ReadAsync(cancellationToken)) { var userId = Guid.Parse(roleReader.GetString(0)); if (!rolesByUser.TryGetValue(userId, out var roles)) rolesByUser[userId] = roles = []; roles.Add(roleReader.GetString(1)); } } var items = users.Select(user => user with { Roles = rolesByUser.GetValueOrDefault(user.Id, []).OrderBy(x => x).ToArray() }).ToArray(); return new PagedResult(items, total, page, pageSize); } finally { await connection.CloseAsync(); } } private static MySqlCommand CreateCommand( MySqlConnection connection, string sql, string? keyword, string? roleName, Guid? collegeId, bool? isEnabled) { var command = new MySqlCommand(sql, connection); if (!string.IsNullOrWhiteSpace(keyword)) command.Parameters.AddWithValue("@keyword", keyword.Trim()); if (!string.IsNullOrWhiteSpace(roleName)) command.Parameters.AddWithValue("@roleName", roleName.Trim()); if (collegeId.HasValue) command.Parameters.AddWithValue("@collegeId", collegeId.Value.ToString()); if (isEnabled.HasValue) command.Parameters.AddWithValue("@isEnabled", isEnabled.Value); return command; } [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);