多角色账号按 All > College > Class > Self 自动取最高权限。

学院管理员限制在所属学院。
辅导员通过稳定账号 ID 绑定行政班,避免重名串班。
教师只能访问本人档案、授课课程和所授课学生。
学生只能访问本人档案及所在班级课程。
教师/学生角色会自动校验并绑定工号或学号档案。
超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
This commit is contained in:
2026-07-24 14:54:30 +08:00 Unverified
parent 0b463fa4f2
commit bcc4d33bd5
26 changed files with 2333 additions and 88 deletions
+8 -4
View File
@@ -53,7 +53,8 @@ public sealed class AuthController(
user.UserName!,
user.DisplayName,
roles,
user.CollegeId));
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
}
[Authorize]
@@ -67,12 +68,14 @@ public sealed class AuthController(
return Unauthorized();
}
var roles = await userManager.GetRolesAsync(user);
return new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
await userManager.GetRolesAsync(user),
user.CollegeId);
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString());
}
}
@@ -87,4 +90,5 @@ public sealed record CurrentUserResponse(
string UserName,
string DisplayName,
IEnumerable<string> Roles,
Guid? CollegeId);
Guid? CollegeId,
string EffectiveDataScope);
@@ -168,7 +168,29 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
x.Id, x.Code, x.Name, x.MajorId,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
x.Grade, x.CounselorName, x.IsEnabled, x.SortOrder
x.Grade, x.CounselorUserId, x.CounselorName,
x.IsEnabled, x.SortOrder
})
.ToListAsync(cancellationToken));
[HttpGet("counselors")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<object>> GetCounselors(CancellationToken cancellationToken) =>
Ok(await db.Users.AsNoTracking()
.Where(user =>
user.IsEnabled &&
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(role =>
role.Id == userRole.RoleId &&
role.Name == SystemRoles.Counselor)))
.OrderBy(x => x.DisplayName)
.Select(x => new
{
x.Id,
x.DisplayName,
x.StaffNumber,
x.CollegeId
})
.ToListAsync(cancellationToken));
@@ -180,6 +202,11 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken))
return ValidationProblem("所选专业不存在。");
var counselorName = await GetCounselorNameAsync(
request.CounselorUserId,
cancellationToken);
if (request.CounselorUserId.HasValue && counselorName is null)
return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。");
var entity = new AdministrativeClass
{
@@ -187,7 +214,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
Name = request.Name.Trim(),
MajorId = request.MajorId,
Grade = request.Grade,
CounselorName = request.CounselorName?.Trim(),
CounselorUserId = request.CounselorUserId,
CounselorName = counselorName,
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
@@ -203,10 +231,16 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
{
var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
var counselorName = await GetCounselorNameAsync(
request.CounselorUserId,
cancellationToken);
if (request.CounselorUserId.HasValue && counselorName is null)
return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。");
ApplyCatalog(entity, request);
entity.MajorId = request.MajorId;
entity.Grade = request.Grade;
entity.CounselorName = request.CounselorName?.Trim();
entity.CounselorUserId = request.CounselorUserId;
entity.CounselorName = counselorName;
await db.SaveChangesAsync(cancellationToken);
return entity;
}
@@ -409,6 +443,24 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
return CreatedAtAction(action, new { id = entity.Id }, entity);
}
private async Task<string?> GetCounselorNameAsync(
Guid? userId,
CancellationToken cancellationToken)
{
if (!userId.HasValue) return null;
return await db.Users.AsNoTracking()
.Where(user =>
user.Id == userId &&
user.IsEnabled &&
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(role =>
role.Id == userRole.RoleId &&
role.Name == SystemRoles.Counselor)))
.Select(x => x.DisplayName)
.FirstOrDefaultAsync(cancellationToken);
}
private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request)
{
entity.Code = request.Code.Trim();
@@ -442,7 +494,7 @@ public sealed record ClassRequest(
string Code, string Name, int SortOrder, bool IsEnabled,
Guid MajorId,
[Range(2000, 2200)] int Grade,
[MaxLength(50)] string? CounselorName)
Guid? CounselorUserId)
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
public sealed record TermRequest(
+32 -13
View File
@@ -1,8 +1,8 @@
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.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/courses")]
public sealed class CoursesController(AppDbContext db) : ControllerBase
public sealed class CoursesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
@@ -32,10 +34,7 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = db.Courses.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
var source = ScopedCourses().AsNoTracking();
if (collegeId.HasValue)
source = source.Where(x => x.CollegeId == collegeId.Value);
if (nature.HasValue)
@@ -162,17 +161,37 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase
return null;
}
private Guid? GetScopedCollegeId()
private IQueryable<Course> ScopedCourses()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin))
return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
var scope = currentUserDataScope.Current;
var source = db.Courses.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.CollegeId == scope.RestrictedCollegeId);
if (scope.Scope == DataScope.Class)
{
return source.Where(course => db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Classes.Any(item =>
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
}
var userId = scope.UserId;
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
var isStudent = scope.IsInRole(SystemRoles.Student);
return source.Where(course =>
isTeacher && db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Teachers.Any(item => item.Teacher!.UserId == userId)) ||
isStudent && db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Classes.Any(item =>
item.AdministrativeClass!.Students.Any(student =>
student.UserId == userId))));
}
private bool CanAccessCollege(Guid collegeId) =>
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
currentUserDataScope.Current.CanAccessCollege(collegeId);
private async Task<ActionResult> SaveAsync(
Guid id,
@@ -1,8 +1,8 @@
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.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/curriculum-plans")]
public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
public sealed class CurriculumPlansController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
@@ -447,12 +449,7 @@ public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
cancellationToken);
private Guid? ScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
=> currentUserDataScope.Current.RestrictedCollegeId;
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
@@ -1,8 +1,8 @@
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.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,13 +13,17 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/personnel")]
public sealed class PersonnelController(AppDbContext db) : ControllerBase
public sealed class PersonnelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor;
SystemRoles.Counselor + "," +
SystemRoles.Teacher + "," +
SystemRoles.Student;
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
@@ -33,10 +37,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
{
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = db.Teachers.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
source = source.Where(x => x.CollegeId == scopedCollegeId.Value);
var source = ApplyTeacherScope(db.Teachers.AsNoTracking());
if (query.CollegeId.HasValue)
source = source.Where(x => x.CollegeId == query.CollegeId);
if (!string.IsNullOrWhiteSpace(query.Keyword))
@@ -155,13 +156,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
{
var page = NormalizePage(query.Page);
var pageSize = NormalizePageSize(query.PageSize);
var source = db.Students.AsNoTracking().AsQueryable();
var scopedCollegeId = GetScopedCollegeId();
if (scopedCollegeId.HasValue)
{
source = source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == scopedCollegeId.Value);
}
var source = ApplyStudentScope(db.Students.AsNoTracking());
if (query.CollegeId.HasValue)
{
source = source.Where(x =>
@@ -303,21 +298,54 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase
return await SaveNoContentAsync(cancellationToken);
}
private Guid? GetScopedCollegeId()
private IQueryable<Teacher> ApplyTeacherScope(IQueryable<Teacher> source)
{
if (!User.IsInRole(SystemRoles.CollegeAdmin) &&
!User.IsInRole(SystemRoles.Counselor))
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.CollegeId == scope.RestrictedCollegeId);
if (scope.Scope == DataScope.Class)
{
return null;
return source.Where(teacher => db.TeachingTasks.Any(task =>
task.Teachers.Any(item => item.TeacherId == teacher.Id) &&
task.Classes.Any(item =>
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
}
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
var userId = scope.UserId;
return scope.IsInRole(SystemRoles.Teacher)
? source.Where(x => x.UserId == userId)
: source.Where(_ => false);
}
private IQueryable<Student> ApplyStudentScope(IQueryable<Student> source)
{
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
{
return source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == scope.RestrictedCollegeId);
}
if (scope.Scope == DataScope.Class)
{
return source.Where(x =>
x.AdministrativeClass!.CounselorUserId == scope.UserId);
}
var userId = scope.UserId;
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
var isStudent = scope.IsInRole(SystemRoles.Student);
return source.Where(student =>
isStudent && student.UserId == userId ||
isTeacher && db.TeachingTasks.Any(task =>
task.Teachers.Any(item => item.Teacher!.UserId == userId) &&
task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)));
}
private bool CanAccessCollege(Guid collegeId) =>
!GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId;
currentUserDataScope.Current.CanAccessCollege(collegeId);
private async Task<ActionResult?> ValidateCollegeAsync(
Guid collegeId,
@@ -1,8 +1,8 @@
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.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ManagementRoles)]
[Route("api/teaching-tasks")]
public sealed class TeachingTasksController(AppDbContext db) : ControllerBase
public sealed class TeachingTasksController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -314,12 +316,7 @@ public sealed class TeachingTasksController(AppDbContext db) : ControllerBase
}
private Guid? ScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
=> currentUserDataScope.Current.RestrictedCollegeId;
private async Task<ActionResult> SaveAsync(
Guid id,
+109 -17
View File
@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
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;
@@ -12,6 +14,7 @@ namespace Jiaowu.Api.Controllers;
[Authorize(Roles = SystemRoles.SuperAdmin)]
[Route("api/users")]
public sealed class UsersController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager) : ControllerBase
{
@@ -53,29 +56,35 @@ public sealed class UsersController(
[HttpPost]
public async Task<ActionResult> Create(CreateUserRequest request)
{
var invalidRoles = request.Roles
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (invalidRoles.Length > 0)
return ValidationProblem($"无效角色:{string.Join("", invalidRoles)}");
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber);
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 = request.StaffNumber?.Trim(),
StaffNumber = staffNumber,
CollegeId = request.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
await using var transaction = await db.Database.BeginTransactionAsync();
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, request.Roles);
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();
await transaction.CommitAsync();
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
}
@@ -99,28 +108,97 @@ public sealed class UsersController(
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var invalidRoles = request.Roles
.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (invalidRoles.Length > 0)
return ValidationProblem($"无效角色:{string.Join("", invalidRoles)}");
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber);
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) &&
!request.Roles.Contains(SystemRoles.SuperAdmin))
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
{
return ValidationProblem("不能移除当前账号的超级管理员角色。");
}
await using var transaction = await db.Database.BeginTransactionAsync();
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(request.Roles));
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
var addResult = await userManager.AddToRolesAsync(
user,
request.Roles.Except(existing));
return addResult.Succeeded ? NoContent() : IdentityValidationProblem(addResult);
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();
await transaction.CommitAsync();
return NoContent();
}
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)
@@ -129,6 +207,17 @@ public sealed class UsersController(
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(
@@ -140,4 +229,7 @@ public sealed record CreateUserRequest(
[MinLength(1)] string[] Roles);
public sealed record SetUserStatusRequest(bool IsEnabled);
public sealed record SetRolesRequest([MinLength(1)] string[] Roles);
public sealed record SetRolesRequest(
[MaxLength(30)] string? StaffNumber,
Guid? CollegeId,
[MinLength(1)] string[] Roles);