多角色账号按 All > College > Class > Self 自动取最高权限。
学院管理员限制在所属学院。 辅导员通过稳定账号 ID 绑定行政班,避免重名串班。 教师只能访问本人档案、授课课程和所授课学生。 学生只能访问本人档案及所在班级课程。 教师/学生角色会自动校验并绑定工号或学号档案。 超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
@@ -28,6 +29,8 @@ public sealed class AdministrativeClass : CatalogEntity
|
||||
public Major? Major { get; set; }
|
||||
public int Grade { get; set; }
|
||||
public string? CounselorName { get; set; }
|
||||
public Guid? CounselorUserId { get; set; }
|
||||
public ApplicationUser? CounselorUser { get; set; }
|
||||
public ICollection<Student> Students { get; set; } = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ICurrentUserDataScope
|
||||
{
|
||||
CurrentUserScope Current { get; }
|
||||
}
|
||||
|
||||
public sealed record CurrentUserScope(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
Guid? CollegeId,
|
||||
DataScope Scope,
|
||||
IReadOnlySet<string> Roles)
|
||||
{
|
||||
public bool IsInRole(string role) => Roles.Contains(role);
|
||||
|
||||
public bool CanAccessCollege(Guid collegeId) =>
|
||||
Scope == DataScope.All ||
|
||||
Scope == DataScope.College && CollegeId == collegeId;
|
||||
|
||||
public Guid? RestrictedCollegeId => Scope switch
|
||||
{
|
||||
DataScope.All => null,
|
||||
DataScope.College => CollegeId ?? Guid.Empty,
|
||||
_ => Guid.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class CurrentUserDataScope(IHttpContextAccessor httpContextAccessor)
|
||||
: ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var principal = httpContextAccessor.HttpContext?.User;
|
||||
var roles = new HashSet<string>(
|
||||
principal?.FindAll(ClaimTypes.Role).Select(x => x.Value) ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new CurrentUserScope(
|
||||
ParseGuid(principal?.FindFirstValue(ClaimTypes.NameIdentifier)),
|
||||
principal?.FindFirstValue("display_name") ??
|
||||
principal?.FindFirstValue(ClaimTypes.Name),
|
||||
ParseNullableGuid(principal?.FindFirstValue("college_id")),
|
||||
EffectiveDataScopeResolver.Resolve(roles),
|
||||
roles);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid ParseGuid(string? value) =>
|
||||
Guid.TryParse(value, out var id) ? id : Guid.Empty;
|
||||
|
||||
private static Guid? ParseNullableGuid(string? value) =>
|
||||
Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
public static class EffectiveDataScopeResolver
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, DataScope> RoleScopes =
|
||||
new Dictionary<string, DataScope>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[SystemRoles.SuperAdmin] = DataScope.All,
|
||||
[SystemRoles.AcademicAdmin] = DataScope.All,
|
||||
[SystemRoles.Leader] = DataScope.All,
|
||||
[SystemRoles.CollegeAdmin] = DataScope.College,
|
||||
[SystemRoles.Counselor] = DataScope.Class,
|
||||
[SystemRoles.Teacher] = DataScope.Self,
|
||||
[SystemRoles.Student] = DataScope.Self
|
||||
};
|
||||
|
||||
public static DataScope Resolve(IEnumerable<string> roles)
|
||||
{
|
||||
var effectiveScope = DataScope.Self;
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (RoleScopes.TryGetValue(role, out var roleScope) &&
|
||||
roleScope > effectiveScope)
|
||||
{
|
||||
effectiveScope = roleScope;
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveScope;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty),
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.DisplayName)
|
||||
new(ClaimTypes.Name, user.DisplayName),
|
||||
new("display_name", user.DisplayName)
|
||||
};
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
@@ -72,6 +72,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.MajorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<AdministrativeClass>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.CounselorUserId);
|
||||
entity.HasOne(x => x.CounselorUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CounselorUserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<Building>()
|
||||
.HasOne(x => x.Campus)
|
||||
|
||||
@@ -419,6 +419,85 @@ public sealed class DatabaseInitializer(
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await SeedDevelopmentUsersAsync(computerCollege.Id);
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
|
||||
{
|
||||
var definitions = new[]
|
||||
{
|
||||
new DevelopmentUser(
|
||||
"academic", "校级教务员", "Academic@123456",
|
||||
null, null, SystemRoles.AcademicAdmin),
|
||||
new DevelopmentUser(
|
||||
"college", "计算机学院教务员", "College@123456",
|
||||
"A2026001", collegeId, SystemRoles.CollegeAdmin),
|
||||
new DevelopmentUser(
|
||||
"counselor", "陈老师", "Counselor@123456",
|
||||
"C2026001", collegeId, SystemRoles.Counselor),
|
||||
new DevelopmentUser(
|
||||
"teacher", "陈明远", "Teacher@123456",
|
||||
"T2026001", collegeId, SystemRoles.Teacher),
|
||||
new DevelopmentUser(
|
||||
"student", "周启航", "Student@123456",
|
||||
"202601001", collegeId, SystemRoles.Student),
|
||||
new DevelopmentUser(
|
||||
"leader", "教学分管领导", "Leader@123456",
|
||||
null, null, SystemRoles.Leader)
|
||||
};
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(definition.UserName);
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = definition.UserName,
|
||||
DisplayName = definition.DisplayName,
|
||||
StaffNumber = definition.StaffNumber,
|
||||
CollegeId = definition.CollegeId,
|
||||
LockoutEnabled = true,
|
||||
IsEnabled = true
|
||||
};
|
||||
EnsureSucceeded(
|
||||
await userManager.CreateAsync(user, definition.Password),
|
||||
$"创建开发账号 {definition.UserName}");
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(user, definition.Role))
|
||||
{
|
||||
EnsureSucceeded(
|
||||
await userManager.AddToRoleAsync(user, definition.Role),
|
||||
$"授予开发账号 {definition.UserName} 角色");
|
||||
}
|
||||
|
||||
if (definition.Role == SystemRoles.Teacher)
|
||||
{
|
||||
var teacher = await db.Teachers.SingleAsync(
|
||||
x => x.TeacherNumber == definition.StaffNumber);
|
||||
if (!teacher.UserId.HasValue) teacher.UserId = user.Id;
|
||||
}
|
||||
if (definition.Role == SystemRoles.Student)
|
||||
{
|
||||
var student = await db.Students.SingleAsync(
|
||||
x => x.StudentNumber == definition.StaffNumber);
|
||||
if (!student.UserId.HasValue) student.UserId = user.Id;
|
||||
}
|
||||
if (definition.Role == SystemRoles.Counselor)
|
||||
{
|
||||
var classes = await db.AdministrativeClasses
|
||||
.Where(x =>
|
||||
x.CounselorUserId == null &&
|
||||
x.CounselorName == definition.DisplayName)
|
||||
.ToListAsync();
|
||||
foreach (var administrativeClass in classes)
|
||||
administrativeClass.CounselorUserId = user.Id;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(IdentityResult result, string action)
|
||||
@@ -431,4 +510,12 @@ public sealed class DatabaseInitializer(
|
||||
throw new InvalidOperationException(
|
||||
$"{action}失败:{string.Join(";", result.Errors.Select(x => x.Description))}");
|
||||
}
|
||||
|
||||
private sealed record DevelopmentUser(
|
||||
string UserName,
|
||||
string DisplayName,
|
||||
string Password,
|
||||
string? StaffNumber,
|
||||
Guid? CollegeId,
|
||||
string Role);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
|
||||
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
|
||||
private const string SchedulesMigration = "20260724_04_schedules";
|
||||
private const string ClassCounselorMigration = "20260724_05_class_counselor";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -43,6 +44,20 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
SchedulesMigration,
|
||||
SchedulesStatements,
|
||||
cancellationToken);
|
||||
var counselorColumnExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AdministrativeClasses')
|
||||
WHERE name = 'CounselorUserId'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
ClassCounselorMigration,
|
||||
counselorColumnExists
|
||||
? ClassCounselorStatements.Skip(1)
|
||||
: ClassCounselorStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -399,4 +414,16 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "ScheduleEntries" ("ClassroomId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ClassCounselorStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "AdministrativeClasses"
|
||||
ADD COLUMN "CounselorUserId" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AdministrativeClasses_CounselorUserId"
|
||||
ON "AdministrativeClasses" ("CounselorUserId");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+1483
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ClassCounselorAssignment : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CounselorUserId",
|
||||
table: "AdministrativeClasses",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AdministrativeClasses_CounselorUserId",
|
||||
table: "AdministrativeClasses",
|
||||
column: "CounselorUserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
|
||||
table: "AdministrativeClasses",
|
||||
column: "CounselorUserId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId",
|
||||
table: "AdministrativeClasses");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AdministrativeClasses_CounselorUserId",
|
||||
table: "AdministrativeClasses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CounselorUserId",
|
||||
table: "AdministrativeClasses");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -89,6 +89,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<string>("CounselorName")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid?>("CounselorUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -117,6 +120,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CounselorUserId");
|
||||
|
||||
b.HasIndex("MajorId");
|
||||
|
||||
b.HasIndex("IsEnabled", "SortOrder");
|
||||
@@ -1154,12 +1159,19 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CounselorUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
|
||||
.WithMany()
|
||||
.HasForeignKey("MajorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CounselorUser");
|
||||
|
||||
b.Navigation("Major");
|
||||
});
|
||||
|
||||
|
||||
@@ -81,7 +81,9 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32)
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user