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.Caching; 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 userManager, IAppCache cache) : 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 SaveCreatedAsync(entity.Id, 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); 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); } [HttpPost("teachers/{id:guid}/activate-account")] [Authorize(Roles = WriteRoles)] public async Task ActivateTeacherAccount( Guid id, TeacherAccountActivationRequest request, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); 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( "该工号已有登录账号但未正确关联,请到账号管理中核对。"); } 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); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); return Ok(new { user.Id, UserName = userName }); }, cancellationToken); } [HttpGet("students")] public async Task>> GetStudents( [FromQuery] PersonnelQuery query, CancellationToken cancellationToken) { var roles = currentUserDataScope.Current.Roles; var canReadFullProfile = roles.Any(role => role is SystemRoles.SuperAdmin or SystemRoles.AcademicAdmin or SystemRoles.CollegeAdmin or SystemRoles.Counselor); 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, EnglishName = canReadFullProfile ? x.EnglishName : null, IdCardNumber = canReadFullProfile ? x.IdCardNumber : null, Nationality = canReadFullProfile ? x.Nationality : null, Ethnicity = canReadFullProfile ? x.Ethnicity : null, PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null, NativePlace = canReadFullProfile ? x.NativePlace : null, HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null, CurrentAddress = canReadFullProfile ? x.CurrentAddress : null, PostalCode = canReadFullProfile ? x.PostalCode : null, x.Phone, x.Email, Qq = canReadFullProfile ? x.Qq : null, x.WeChat, x.EmergencyContactName, x.EmergencyContactRelationship, x.EmergencyContactPhone, x.SpecialTags, x.SpecialNeeds, Biography = canReadFullProfile ? x.Biography : null, Notes = canReadFullProfile ? x.Notes : null, 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, EnglishName = Normalize(request.EnglishName), IdCardNumber = Normalize(request.IdCardNumber), Nationality = Normalize(request.Nationality), Ethnicity = Normalize(request.Ethnicity), PoliticalStatus = Normalize(request.PoliticalStatus), NativePlace = Normalize(request.NativePlace), HouseholdAddress = Normalize(request.HouseholdAddress), CurrentAddress = Normalize(request.CurrentAddress), PostalCode = Normalize(request.PostalCode), Phone = Normalize(request.Phone), Email = Normalize(request.Email), Qq = Normalize(request.Qq), WeChat = Normalize(request.WeChat), EmergencyContactName = Normalize(request.EmergencyContactName), EmergencyContactRelationship = Normalize( request.EmergencyContactRelationship), EmergencyContactPhone = Normalize(request.EmergencyContactPhone), SpecialTags = Normalize(request.SpecialTags), SpecialNeeds = Normalize(request.SpecialNeeds), Biography = Normalize(request.Biography), Notes = Normalize(request.Notes) }; db.Students.Add(entity); return await SaveCreatedAsync(entity.Id, 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.EnglishName = Normalize(request.EnglishName); entity.IdCardNumber = Normalize(request.IdCardNumber); entity.Nationality = Normalize(request.Nationality); entity.Ethnicity = Normalize(request.Ethnicity); entity.PoliticalStatus = Normalize(request.PoliticalStatus); entity.NativePlace = Normalize(request.NativePlace); entity.HouseholdAddress = Normalize(request.HouseholdAddress); entity.CurrentAddress = Normalize(request.CurrentAddress); entity.PostalCode = Normalize(request.PostalCode); entity.Phone = Normalize(request.Phone); entity.Email = Normalize(request.Email); entity.Qq = Normalize(request.Qq); entity.WeChat = Normalize(request.WeChat); entity.EmergencyContactName = Normalize(request.EmergencyContactName); entity.EmergencyContactRelationship = Normalize( request.EmergencyContactRelationship); entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone); entity.SpecialTags = Normalize(request.SpecialTags); entity.SpecialNeeds = Normalize(request.SpecialNeeds); entity.Biography = Normalize(request.Biography); entity.Notes = Normalize(request.Notes); 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); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Analytics, cancellationToken); return Created(string.Empty, new { id }); } catch (DbUpdateException) { return ConflictProblem("编号已存在,或关联数据无效。"); } } private async Task SaveNoContentAsync(CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Analytics, 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(100)] string? EnglishName, [MaxLength(30)] string? IdCardNumber, [MaxLength(50)] string? Nationality, [MaxLength(50)] string? Ethnicity, [MaxLength(50)] string? PoliticalStatus, [MaxLength(100)] string? NativePlace, [MaxLength(300)] string? HouseholdAddress, [MaxLength(300)] string? CurrentAddress, [MaxLength(20)] string? PostalCode, [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, [MaxLength(30)] string? Qq, [MaxLength(60)] string? WeChat, [MaxLength(50)] string? EmergencyContactName, [MaxLength(30)] string? EmergencyContactRelationship, [MaxLength(30)] string? EmergencyContactPhone, [MaxLength(300)] string? SpecialTags, [MaxLength(1000)] string? SpecialNeeds, [MaxLength(1000)] string? Biography, [MaxLength(500)] string? Notes); public sealed record TeacherAccountActivationRequest( [Required, MinLength(8), MaxLength(100)] string Password);