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.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ReadRoles)] [Route("api/personnel")] public sealed class PersonnelController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, PersonnelAccountService personnelAccountService) : 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>> 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(items, total, page, pageSize)); } [HttpPost("teachers")] [Authorize(Roles = WriteRoles)] public async Task 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 CreateWithAccountAsync( entity.Id, request.InitialPassword, () => personnelAccountService.EnsureTeacherAccountAsync( entity, request.InitialPassword, cancellationToken), cancellationToken); } [HttpPut("teachers/{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task 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); if (!entity.UserId.HasValue && (string.IsNullOrWhiteSpace(request.InitialPassword) || request.InitialPassword.Length < 8)) return ValidationProblem("该教师档案尚未开通账号,请填写至少 8 位初始密码。"); if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword)) { var account = await personnelAccountService.EnsureTeacherAccountAsync( entity, request.InitialPassword, cancellationToken); if (!account.Success) return AccountProblem(account.Error!); } return await SaveNoContentAsync(cancellationToken); } [HttpDelete("teachers/{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task 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); } [HttpGet("students")] public async Task>> 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(items, total, page, pageSize)); } [HttpPost("students")] [Authorize(Roles = WriteRoles)] public async Task 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 CreateWithAccountAsync( entity.Id, request.InitialPassword, () => personnelAccountService.EnsureStudentAccountAsync( entity, request.InitialPassword, cancellationToken), cancellationToken); } [HttpPut("students/{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task 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); if (!entity.UserId.HasValue && (string.IsNullOrWhiteSpace(request.InitialPassword) || request.InitialPassword.Length < 8)) return ValidationProblem("该学生档案尚未开通账号,请填写至少 8 位初始密码。"); if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword)) { var account = await personnelAccountService.EnsureStudentAccountAsync( entity, request.InitialPassword, cancellationToken); if (!account.Success) return AccountProblem(account.Error!); } return await SaveNoContentAsync(cancellationToken); } [HttpDelete("students/{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task 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 ApplyTeacherScope(IQueryable 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 ApplyStudentScope(IQueryable 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 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 SaveCreatedAsync( Guid id, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { id }); } catch (DbUpdateException) { return ConflictProblem("编号已存在,或关联数据无效。"); } } private async Task CreateWithAccountAsync( Guid id, string? initialPassword, Func> createAccount, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(initialPassword) || initialPassword.Length < 8) return ValidationProblem("新增人员时必须填写至少 8 位初始密码。"); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); try { var account = await createAccount(); if (!account.Success) { await transaction.RollbackAsync(cancellationToken); return AccountProblem(account.Error!); } await transaction.CommitAsync(cancellationToken); return Created(string.Empty, new { id, account.UserId, account.UserName }); } catch (DbUpdateException) { await transaction.RollbackAsync(cancellationToken); return ConflictProblem("编号已存在,或关联数据无效。"); } } private async Task 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 AccountProblem(string detail) => Conflict(new ProblemDetails { Title = "登录账号创建失败", Detail = detail, Status = StatusCodes.Status409Conflict }); 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, [MinLength(8), MaxLength(100)] string? InitialPassword = null); 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, [MinLength(8), MaxLength(100)] string? InitialPassword = null);