461 lines
20 KiB
C#
461 lines
20 KiB
C#
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<ApplicationUser> userManager,
|
|
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
|
{
|
|
[HttpGet]
|
|
public async Task<ActionResult<PagedResult<UserListItem>>> 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<string>)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<string>())))
|
|
.ToArray();
|
|
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
|
|
}
|
|
|
|
private async Task<PagedResult<UserListItem>> GetUsersFromMySqlAsync(
|
|
int page,
|
|
int pageSize,
|
|
string? keyword,
|
|
string? roleName,
|
|
Guid? collegeId,
|
|
bool? isEnabled,
|
|
bool? hasLoggedIn,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var clauses = new List<string>();
|
|
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<UserListItem>();
|
|
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<string>()));
|
|
}
|
|
}
|
|
|
|
var rolesByUser = new Dictionary<Guid, List<string>>();
|
|
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<UserListItem>(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<ActionResult<object>> 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<ActionResult> 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<ActionResult>(
|
|
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<IActionResult> 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<IActionResult> 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<IActionResult>(
|
|
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<string> 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<string> 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<string> Roles);
|
|
public sealed record SetRolesRequest(
|
|
[MaxLength(30)] string? StaffNumber,
|
|
Guid? CollegeId,
|
|
[MinLength(1)] string[] Roles);
|