506 lines
19 KiB
C#
506 lines
19 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
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.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize(Roles = ReadRoles)]
|
|
[Route("api/personnel")]
|
|
public sealed class PersonnelController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope,
|
|
UserManager<ApplicationUser> userManager) : ControllerBase
|
|
{
|
|
private const string ReadRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin + "," +
|
|
SystemRoles.Counselor + "," +
|
|
SystemRoles.Teacher + "," +
|
|
SystemRoles.Student;
|
|
|
|
private const string WriteRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin;
|
|
|
|
[HttpGet("teachers")]
|
|
public async Task<ActionResult<PagedResult<object>>> GetTeachers(
|
|
[FromQuery] PersonnelQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var page = NormalizePage(query.Page);
|
|
var pageSize = NormalizePageSize(query.PageSize);
|
|
var source = ApplyTeacherScope(db.Teachers.AsNoTracking());
|
|
if (query.CollegeId.HasValue)
|
|
source = source.Where(x => x.CollegeId == query.CollegeId);
|
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
|
{
|
|
var keyword = query.Keyword.Trim();
|
|
source = source.Where(x =>
|
|
x.TeacherNumber.Contains(keyword) ||
|
|
x.Name.Contains(keyword) ||
|
|
(x.Title != null && x.Title.Contains(keyword)));
|
|
}
|
|
if (query.TeacherStatus.HasValue)
|
|
source = source.Where(x => x.Status == query.TeacherStatus);
|
|
|
|
var total = await source.CountAsync(cancellationToken);
|
|
var items = await source
|
|
.OrderBy(x => x.TeacherNumber)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TeacherNumber,
|
|
x.Name,
|
|
x.Gender,
|
|
x.CollegeId,
|
|
CollegeName = x.College!.Name,
|
|
x.Title,
|
|
x.Status,
|
|
x.HireDate,
|
|
x.IsExternal,
|
|
x.Phone,
|
|
x.Email,
|
|
x.Notes,
|
|
x.UserId,
|
|
x.CreatedAt
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpPost("teachers")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<ActionResult> CreateTeacher(
|
|
TeacherRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var validation = await ValidateCollegeAsync(request.CollegeId, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
|
|
var entity = new Teacher
|
|
{
|
|
TeacherNumber = request.TeacherNumber.Trim(),
|
|
Name = request.Name.Trim(),
|
|
Gender = request.Gender,
|
|
CollegeId = request.CollegeId,
|
|
Title = Normalize(request.Title),
|
|
Status = request.Status,
|
|
HireDate = request.HireDate,
|
|
IsExternal = request.IsExternal,
|
|
Phone = Normalize(request.Phone),
|
|
Email = Normalize(request.Email),
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
db.Teachers.Add(entity);
|
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("teachers/{id:guid}")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<ActionResult> UpdateTeacher(
|
|
Guid id,
|
|
TeacherRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entity = await db.Teachers.FindAsync([id], cancellationToken);
|
|
if (entity is null) return NotFound();
|
|
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
|
|
var validation = await ValidateCollegeAsync(request.CollegeId, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
|
|
entity.TeacherNumber = request.TeacherNumber.Trim();
|
|
entity.Name = request.Name.Trim();
|
|
entity.Gender = request.Gender;
|
|
entity.CollegeId = request.CollegeId;
|
|
entity.Title = Normalize(request.Title);
|
|
entity.Status = request.Status;
|
|
entity.HireDate = request.HireDate;
|
|
entity.IsExternal = request.IsExternal;
|
|
entity.Phone = Normalize(request.Phone);
|
|
entity.Email = Normalize(request.Email);
|
|
entity.Notes = Normalize(request.Notes);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("teachers/{id:guid}")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<IActionResult> DeleteTeacher(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var entity = await db.Teachers.FindAsync([id], cancellationToken);
|
|
if (entity is null) return NotFound();
|
|
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
|
|
if (entity.UserId.HasValue)
|
|
{
|
|
return ConflictProblem("教师已关联登录账号,请先解除账号关联后再删除。");
|
|
}
|
|
|
|
db.Teachers.Remove(entity);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpPost("teachers/{id:guid}/activate-account")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<ActionResult> ActivateTeacherAccount(
|
|
Guid id,
|
|
TeacherAccountActivationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
|
|
if (teacher is null) return NotFound();
|
|
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
|
|
if (teacher.Status != TeacherStatus.Active)
|
|
{
|
|
return ConflictProblem("仅在职教师可以激活登录账号。");
|
|
}
|
|
if (teacher.UserId.HasValue)
|
|
{
|
|
return ConflictProblem("该教师档案已经关联登录账号。");
|
|
}
|
|
|
|
var userName = teacher.TeacherNumber.Trim();
|
|
if (await userManager.FindByNameAsync(userName) is not null)
|
|
{
|
|
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
|
}
|
|
|
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
|
var user = new ApplicationUser
|
|
{
|
|
UserName = userName,
|
|
DisplayName = teacher.Name,
|
|
StaffNumber = userName,
|
|
CollegeId = teacher.CollegeId,
|
|
IsEnabled = true,
|
|
LockoutEnabled = true
|
|
};
|
|
var result = await userManager.CreateAsync(user, request.Password);
|
|
if (!result.Succeeded)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return IdentityValidationProblem(result);
|
|
}
|
|
|
|
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher);
|
|
if (!result.Succeeded)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return IdentityValidationProblem(result);
|
|
}
|
|
|
|
teacher.UserId = user.Id;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return Ok(new { user.Id, UserName = userName });
|
|
}
|
|
|
|
[HttpGet("students")]
|
|
public async Task<ActionResult<PagedResult<object>>> GetStudents(
|
|
[FromQuery] PersonnelQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var page = NormalizePage(query.Page);
|
|
var pageSize = NormalizePageSize(query.PageSize);
|
|
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
|
if (query.CollegeId.HasValue)
|
|
{
|
|
source = source.Where(x =>
|
|
x.AdministrativeClass!.Major!.CollegeId == query.CollegeId.Value);
|
|
}
|
|
if (query.MajorId.HasValue)
|
|
source = source.Where(x => x.AdministrativeClass!.MajorId == query.MajorId);
|
|
if (query.ClassId.HasValue)
|
|
source = source.Where(x => x.AdministrativeClassId == query.ClassId);
|
|
if (query.EnrollmentYear.HasValue)
|
|
source = source.Where(x => x.EnrollmentYear == query.EnrollmentYear);
|
|
if (query.StudentStatus.HasValue)
|
|
source = source.Where(x => x.Status == query.StudentStatus);
|
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
|
{
|
|
var keyword = query.Keyword.Trim();
|
|
source = source.Where(x =>
|
|
x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword));
|
|
}
|
|
|
|
var total = await source.CountAsync(cancellationToken);
|
|
var items = await source
|
|
.OrderBy(x => x.StudentNumber)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.StudentNumber,
|
|
x.Name,
|
|
x.Gender,
|
|
x.AdministrativeClassId,
|
|
ClassName = x.AdministrativeClass!.Name,
|
|
MajorId = x.AdministrativeClass.MajorId,
|
|
MajorName = x.AdministrativeClass.Major!.Name,
|
|
CollegeId = x.AdministrativeClass.Major.CollegeId,
|
|
CollegeName = x.AdministrativeClass.Major.College!.Name,
|
|
x.EnrollmentYear,
|
|
x.EnrollmentDate,
|
|
x.Status,
|
|
x.DateOfBirth,
|
|
x.Phone,
|
|
x.Email,
|
|
x.Notes,
|
|
x.UserId,
|
|
x.CreatedAt
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpPost("students")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<ActionResult> CreateStudent(
|
|
StudentRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var targetClass = await db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x => x.Id == request.AdministrativeClassId)
|
|
.Select(x => new { x.Id, CollegeId = x.Major!.CollegeId })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (targetClass is null)
|
|
return ValidationProblem("所选行政班不存在。");
|
|
if (!CanAccessCollege(targetClass.CollegeId))
|
|
return Forbid();
|
|
|
|
var entity = new Student
|
|
{
|
|
StudentNumber = request.StudentNumber.Trim(),
|
|
Name = request.Name.Trim(),
|
|
Gender = request.Gender,
|
|
AdministrativeClassId = request.AdministrativeClassId,
|
|
EnrollmentYear = request.EnrollmentYear,
|
|
EnrollmentDate = request.EnrollmentDate,
|
|
Status = request.Status,
|
|
DateOfBirth = request.DateOfBirth,
|
|
Phone = Normalize(request.Phone),
|
|
Email = Normalize(request.Email),
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
db.Students.Add(entity);
|
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("students/{id:guid}")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<ActionResult> UpdateStudent(
|
|
Guid id,
|
|
StudentRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entity = await db.Students
|
|
.Include(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (entity is null) return NotFound();
|
|
if (!CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) return Forbid();
|
|
|
|
var targetClass = await db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x => x.Id == request.AdministrativeClassId)
|
|
.Select(x => new { x.Id, CollegeId = x.Major!.CollegeId })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (targetClass is null)
|
|
return ValidationProblem("所选行政班不存在。");
|
|
if (!CanAccessCollege(targetClass.CollegeId))
|
|
return Forbid();
|
|
|
|
entity.StudentNumber = request.StudentNumber.Trim();
|
|
entity.Name = request.Name.Trim();
|
|
entity.Gender = request.Gender;
|
|
entity.AdministrativeClassId = request.AdministrativeClassId;
|
|
entity.EnrollmentYear = request.EnrollmentYear;
|
|
entity.EnrollmentDate = request.EnrollmentDate;
|
|
entity.Status = request.Status;
|
|
entity.DateOfBirth = request.DateOfBirth;
|
|
entity.Phone = Normalize(request.Phone);
|
|
entity.Email = Normalize(request.Email);
|
|
entity.Notes = Normalize(request.Notes);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("students/{id:guid}")]
|
|
[Authorize(Roles = WriteRoles)]
|
|
public async Task<IActionResult> DeleteStudent(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var entity = await db.Students
|
|
.Include(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (entity is null) return NotFound();
|
|
if (!CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) return Forbid();
|
|
if (entity.UserId.HasValue)
|
|
{
|
|
return ConflictProblem("学生已关联登录账号,请先解除账号关联后再删除。");
|
|
}
|
|
|
|
db.Students.Remove(entity);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
private IQueryable<Teacher> ApplyTeacherScope(IQueryable<Teacher> source)
|
|
{
|
|
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 source.Where(teacher => db.TeachingTasks.Any(task =>
|
|
task.Teachers.Any(item => item.TeacherId == teacher.Id) &&
|
|
task.Classes.Any(item =>
|
|
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
|
|
}
|
|
|
|
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) =>
|
|
currentUserDataScope.Current.CanAccessCollege(collegeId);
|
|
|
|
private async Task<ActionResult?> ValidateCollegeAsync(
|
|
Guid collegeId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!CanAccessCollege(collegeId)) return Forbid();
|
|
if (!await db.Colleges.AnyAsync(x => x.Id == collegeId, cancellationToken))
|
|
return ValidationProblem("所选学院不存在。");
|
|
return null;
|
|
}
|
|
|
|
private async Task<ActionResult> SaveCreatedAsync(
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return Created(string.Empty, new { id });
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("编号已存在,或关联数据无效。");
|
|
}
|
|
}
|
|
|
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("编号已存在,或该档案已被其他业务引用。");
|
|
}
|
|
}
|
|
|
|
private ActionResult ConflictProblem(string detail) =>
|
|
Conflict(new ProblemDetails
|
|
{
|
|
Title = "无法完成操作",
|
|
Detail = detail,
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
|
|
private ActionResult IdentityValidationProblem(IdentityResult result)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
ModelState.AddModelError(error.Code, error.Description);
|
|
return ValidationProblem(ModelState);
|
|
}
|
|
|
|
private static string? Normalize(string? value) =>
|
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
private static int NormalizePage(int page) => Math.Max(1, page);
|
|
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 10, 100);
|
|
}
|
|
|
|
public sealed record PersonnelQuery(
|
|
int Page = 1,
|
|
int PageSize = 20,
|
|
string? Keyword = null,
|
|
Guid? CollegeId = null,
|
|
Guid? MajorId = null,
|
|
Guid? ClassId = null,
|
|
int? EnrollmentYear = null,
|
|
TeacherStatus? TeacherStatus = null,
|
|
StudentStatus? StudentStatus = null);
|
|
|
|
public sealed record TeacherRequest(
|
|
[Required, MaxLength(30)] string TeacherNumber,
|
|
[Required, MaxLength(50)] string Name,
|
|
Gender Gender,
|
|
Guid CollegeId,
|
|
[MaxLength(30)] string? Title,
|
|
TeacherStatus Status,
|
|
DateOnly? HireDate,
|
|
bool IsExternal,
|
|
[MaxLength(30)] string? Phone,
|
|
[EmailAddress, MaxLength(100)] string? Email,
|
|
[MaxLength(500)] string? Notes);
|
|
|
|
public sealed record StudentRequest(
|
|
[Required, MaxLength(30)] string StudentNumber,
|
|
[Required, MaxLength(50)] string Name,
|
|
Gender Gender,
|
|
Guid AdministrativeClassId,
|
|
[Range(2000, 2200)] int EnrollmentYear,
|
|
DateOnly EnrollmentDate,
|
|
StudentStatus Status,
|
|
DateOnly? DateOfBirth,
|
|
[MaxLength(30)] string? Phone,
|
|
[EmailAddress, MaxLength(100)] string? Email,
|
|
[MaxLength(500)] string? Notes);
|
|
|
|
public sealed record TeacherAccountActivationRequest(
|
|
[Required, MinLength(8), MaxLength(100)] string Password);
|