diff --git a/README.md b/README.md index fba0570..a9bfe22 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查。 + ## 本地开发:热更新模式 本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。 @@ -55,7 +57,7 @@ dotnet tool restore dotnet ef migrations list --project src/Jiaowu.Api --startup-project src/Jiaowu.Api ``` -应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发,并通过 `EnsureCreated` 建立本地数据库;开发模型变化后,可删除本地 SQLite 文件重新生成,不能把该文件用于生产。 +应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开发库通过轻量、版本化的本地升级脚本补齐结构,不需要手动删除数据文件。SQLite 文件不能用于生产。 ## 验证 diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index dc86fdf..2d0f4b2 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -66,6 +66,9 @@ try { $headers = @{ Authorization = "Bearer $($login.token)" } $dashboard = Invoke-RestMethod -Uri 'http://localhost:5255/api/dashboard' -Headers $headers $campuses = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/campuses' -Headers $headers + $teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers + $students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers + $courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $unknownApiParameters = @{ @@ -81,6 +84,9 @@ try { User = $login.user.displayName Term = $dashboard.currentTerm.name Campuses = @($campuses).Count + Teachers = $teachers.total + Students = $students.total + Courses = $courses.total StaticIndex = $frontend.Content.Contains('明序教务管理系统') SpaFallback = $spaFallback.StatusCode ApiNotFound = $unknownApi.StatusCode diff --git a/scripts/start-visual-test.ps1 b/scripts/start-visual-test.ps1 new file mode 100644 index 0000000..41cf091 --- /dev/null +++ b/scripts/start-visual-test.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' +$env:ASPNETCORE_ENVIRONMENT = 'Development' +$env:ASPNETCORE_URLS = 'http://localhost:5256' + +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$publishRoot = Join-Path $workspaceRoot '.artifacts/publish' +$process = Start-Process ` + -FilePath (Join-Path $publishRoot 'Jiaowu.Api.exe') ` + -WorkingDirectory $publishRoot ` + -WindowStyle Hidden ` + -PassThru + +$process.Id diff --git a/src/Jiaowu.Api/Contracts/PagedResult.cs b/src/Jiaowu.Api/Contracts/PagedResult.cs new file mode 100644 index 0000000..e1998d8 --- /dev/null +++ b/src/Jiaowu.Api/Contracts/PagedResult.cs @@ -0,0 +1,7 @@ +namespace Jiaowu.Api.Contracts; + +public sealed record PagedResult( + IReadOnlyCollection Items, + int Total, + int Page, + int PageSize); diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs new file mode 100644 index 0000000..5bf045f --- /dev/null +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -0,0 +1,215 @@ +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.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/courses")] +public sealed class CoursesController(AppDbContext db) : ControllerBase +{ + private const string WriteRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + [HttpGet] + public async Task>> Get( + int page = 1, + int pageSize = 20, + string? keyword = null, + Guid? collegeId = null, + CourseNature? nature = null, + bool? isEnabled = null, + CancellationToken cancellationToken = default) + { + 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); + if (collegeId.HasValue) + source = source.Where(x => x.CollegeId == collegeId.Value); + if (nature.HasValue) + source = source.Where(x => x.Nature == nature.Value); + if (isEnabled.HasValue) + source = source.Where(x => x.IsEnabled == isEnabled.Value); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + source = source.Where(x => + x.Code.Contains(keyword) || + x.Name.Contains(keyword) || + (x.EnglishName != null && x.EnglishName.Contains(keyword))); + } + + var total = await source.CountAsync(cancellationToken); + var items = await source + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.EnglishName, + x.CollegeId, + CollegeName = x.College!.Name, + x.Credits, + x.TotalHours, + x.LectureHours, + x.PracticeHours, + x.Nature, + x.AssessmentMethod, + x.Description, + x.IsEnabled, + x.SortOrder, + x.CreatedAt + }) + .ToListAsync(cancellationToken); + + return Ok(new PagedResult(items, total, page, pageSize)); + } + + [HttpPost] + [Authorize(Roles = WriteRoles)] + public async Task Create( + CourseRequest request, + CancellationToken cancellationToken) + { + var validation = await ValidateAsync(request, cancellationToken); + if (validation is not null) return validation; + + var entity = new Course + { + Code = request.Code.Trim(), + Name = request.Name.Trim(), + EnglishName = Normalize(request.EnglishName), + CollegeId = request.CollegeId, + Credits = request.Credits, + TotalHours = request.TotalHours, + LectureHours = request.LectureHours, + PracticeHours = request.PracticeHours, + Nature = request.Nature, + AssessmentMethod = request.AssessmentMethod, + Description = Normalize(request.Description), + IsEnabled = request.IsEnabled, + SortOrder = request.SortOrder + }; + db.Courses.Add(entity); + return await SaveAsync(entity.Id, true, cancellationToken); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = WriteRoles)] + public async Task Update( + Guid id, + CourseRequest request, + CancellationToken cancellationToken) + { + var entity = await db.Courses.FindAsync([id], cancellationToken); + if (entity is null) return NotFound(); + if (!CanAccessCollege(entity.CollegeId)) return Forbid(); + var validation = await ValidateAsync(request, cancellationToken); + if (validation is not null) return validation; + + entity.Code = request.Code.Trim(); + entity.Name = request.Name.Trim(); + entity.EnglishName = Normalize(request.EnglishName); + entity.CollegeId = request.CollegeId; + entity.Credits = request.Credits; + entity.TotalHours = request.TotalHours; + entity.LectureHours = request.LectureHours; + entity.PracticeHours = request.PracticeHours; + entity.Nature = request.Nature; + entity.AssessmentMethod = request.AssessmentMethod; + entity.Description = Normalize(request.Description); + entity.IsEnabled = request.IsEnabled; + entity.SortOrder = request.SortOrder; + return await SaveAsync(entity.Id, false, cancellationToken); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = WriteRoles)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + var entity = await db.Courses.FindAsync([id], cancellationToken); + if (entity is null) return NotFound(); + if (!CanAccessCollege(entity.CollegeId)) return Forbid(); + db.Courses.Remove(entity); + return await SaveAsync(id, false, cancellationToken); + } + + private async Task ValidateAsync( + CourseRequest request, + CancellationToken cancellationToken) + { + if (!CanAccessCollege(request.CollegeId)) return Forbid(); + if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken)) + return ValidationProblem("所选学院不存在。"); + if (request.LectureHours + request.PracticeHours > request.TotalHours) + return ValidationProblem("讲授学时与实践学时之和不能超过总学时。"); + return null; + } + + private Guid? GetScopedCollegeId() + { + if (!User.IsInRole(SystemRoles.CollegeAdmin)) + return null; + return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) + ? collegeId + : Guid.Empty; + } + + private bool CanAccessCollege(Guid collegeId) => + !GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId; + + private async Task SaveAsync( + Guid id, + bool created, + CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + return created ? Created(string.Empty, new { id }) : NoContent(); + } + catch (DbUpdateException) + { + return Conflict(new ProblemDetails + { + Title = "无法完成操作", + Detail = "课程编码已存在,或课程已被其他教学业务引用。", + Status = StatusCodes.Status409Conflict + }); + } + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +public sealed record CourseRequest( + [Required, MaxLength(40)] string Code, + [Required, MaxLength(100)] string Name, + [MaxLength(150)] string? EnglishName, + Guid CollegeId, + [Range(typeof(decimal), "0.1", "99")] decimal Credits, + [Range(1, 1000)] int TotalHours, + [Range(0, 1000)] int LectureHours, + [Range(0, 1000)] int PracticeHours, + CourseNature Nature, + AssessmentMethod AssessmentMethod, + [MaxLength(1000)] string? Description, + bool IsEnabled, + int SortOrder); diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index fce902a..1e9f71b 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -29,6 +29,9 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase Majors = await db.Majors.CountAsync(cancellationToken), Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), Classrooms = await db.Classrooms.CountAsync(cancellationToken), + Teachers = await db.Teachers.CountAsync(cancellationToken), + Students = await db.Students.CountAsync(cancellationToken), + Courses = await db.Courses.CountAsync(cancellationToken), Users = await db.Users.CountAsync(cancellationToken) } }; diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs new file mode 100644 index 0000000..34bb768 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -0,0 +1,410 @@ +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.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) : ControllerBase +{ + private const string ReadRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Counselor; + + 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 = db.Teachers.AsNoTracking().AsQueryable(); + var scopedCollegeId = GetScopedCollegeId(); + if (scopedCollegeId.HasValue) + source = source.Where(x => x.CollegeId == scopedCollegeId.Value); + 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); + } + + [HttpGet("students")] + public async Task>> GetStudents( + [FromQuery] PersonnelQuery query, + CancellationToken cancellationToken) + { + 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); + } + 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 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.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 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 Guid? GetScopedCollegeId() + { + if (!User.IsInRole(SystemRoles.CollegeAdmin) && + !User.IsInRole(SystemRoles.Counselor)) + { + return null; + } + + return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) + ? collegeId + : Guid.Empty; + } + + private bool CanAccessCollege(Guid collegeId) => + !GetScopedCollegeId().HasValue || GetScopedCollegeId() == 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 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 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); diff --git a/src/Jiaowu.Api/Domain/Academic/PeopleAndCourseEntities.cs b/src/Jiaowu.Api/Domain/Academic/PeopleAndCourseEntities.cs new file mode 100644 index 0000000..8fc442b --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/PeopleAndCourseEntities.cs @@ -0,0 +1,89 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class Teacher : EntityBase +{ + public required string TeacherNumber { get; set; } + public required string Name { get; set; } + public Gender Gender { get; set; } + public Guid CollegeId { get; set; } + public College? College { get; set; } + public string? Title { get; set; } + public TeacherStatus Status { get; set; } = TeacherStatus.Active; + public DateOnly? HireDate { get; set; } + public bool IsExternal { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + public Guid? UserId { get; set; } +} + +public sealed class Student : EntityBase +{ + public required string StudentNumber { get; set; } + public required string Name { get; set; } + public Gender Gender { get; set; } + public Guid AdministrativeClassId { get; set; } + public AdministrativeClass? AdministrativeClass { get; set; } + public int EnrollmentYear { get; set; } + public DateOnly EnrollmentDate { get; set; } + public StudentStatus Status { get; set; } = StudentStatus.Active; + public DateOnly? DateOfBirth { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + public Guid? UserId { get; set; } +} + +public sealed class Course : CatalogEntity +{ + public Guid CollegeId { get; set; } + public College? College { get; set; } + public string? EnglishName { get; set; } + public decimal Credits { get; set; } + public int TotalHours { get; set; } + public int LectureHours { get; set; } + public int PracticeHours { get; set; } + public CourseNature Nature { get; set; } + public AssessmentMethod AssessmentMethod { get; set; } + public string? Description { get; set; } +} + +public enum Gender +{ + Unknown = 0, + Male = 1, + Female = 2 +} + +public enum TeacherStatus +{ + Active = 1, + OnLeave = 2, + Retired = 3, + Departed = 4 +} + +public enum StudentStatus +{ + Active = 1, + Suspended = 2, + Graduated = 3, + Withdrawn = 4 +} + +public enum CourseNature +{ + GeneralRequired = 1, + MajorRequired = 2, + MajorElective = 3, + GeneralElective = 4, + Practice = 5 +} + +public enum AssessmentMethod +{ + Examination = 1, + Assessment = 2 +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 9c0229e..0e92bac 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -17,6 +17,9 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet Buildings => Set(); public DbSet Classrooms => Set(); public DbSet AcademicTerms => Set(); + public DbSet Teachers => Set(); + public DbSet Students => Set(); + public DbSet Courses => Set(); public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -42,6 +45,7 @@ public sealed class AppDbContext(DbContextOptions options) ConfigureCatalog(builder); ConfigureCatalog(builder); ConfigureCatalog(builder); + ConfigureCatalog(builder); builder.Entity() .HasOne(x => x.Campus) @@ -76,6 +80,58 @@ public sealed class AppDbContext(DbContextOptions options) builder.Entity() .HasIndex(x => x.IsCurrent); + builder.Entity(entity => + { + entity.Property(x => x.TeacherNumber).HasMaxLength(30); + entity.Property(x => x.Name).HasMaxLength(50); + entity.Property(x => x.Title).HasMaxLength(30); + entity.Property(x => x.Phone).HasMaxLength(30); + entity.Property(x => x.Email).HasMaxLength(100); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => x.TeacherNumber).IsUnique(); + entity.HasIndex(x => new { x.CollegeId, x.Status }); + entity.HasOne(x => x.College) + .WithMany() + .HasForeignKey(x => x.CollegeId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.StudentNumber).HasMaxLength(30); + entity.Property(x => x.Name).HasMaxLength(50); + entity.Property(x => x.Phone).HasMaxLength(30); + entity.Property(x => x.Email).HasMaxLength(100); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => x.StudentNumber).IsUnique(); + entity.HasIndex(x => new { x.AdministrativeClassId, x.Status }); + entity.HasIndex(x => x.EnrollmentYear); + entity.HasOne(x => x.AdministrativeClass) + .WithMany() + .HasForeignKey(x => x.AdministrativeClassId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.EnglishName).HasMaxLength(150); + entity.Property(x => x.Credits).HasPrecision(5, 2); + entity.Property(x => x.Description).HasMaxLength(1000); + entity.HasIndex(x => new { x.CollegeId, x.Nature }); + entity.HasOne(x => x.College) + .WithMany() + .HasForeignKey(x => x.CollegeId) + .OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => { entity.Property(x => x.Method).HasMaxLength(10); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index bc78a81..2a1ddd5 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -9,6 +9,7 @@ public sealed class DatabaseInitializer( AppDbContext db, RoleManager roleManager, UserManager userManager, + DevelopmentSqliteMigrator sqliteMigrator, IConfiguration configuration, IHostEnvironment environment, ILogger logger) @@ -18,6 +19,7 @@ public sealed class DatabaseInitializer( if (environment.IsDevelopment()) { await db.Database.EnsureCreatedAsync(); + await sqliteMigrator.MigrateAsync(); } else { @@ -108,71 +110,182 @@ public sealed class DatabaseInitializer( private async Task SeedDevelopmentDataAsync() { - if (await db.Campuses.AnyAsync()) + if (!await db.Campuses.AnyAsync()) { - return; + var campus = new Campus + { + Code = "MAIN", + Name = "主校区", + Address = "大学路 1 号" + }; + var college = new College + { + Code = "CS", + Name = "计算机学院", + ShortName = "计算机学院", + CampusId = campus.Id + }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士", + SchoolingYears = 4 + }; + var building = new Building + { + Code = "J1", + Name = "第一教学楼", + CampusId = campus.Id + }; + + db.AddRange( + campus, + college, + major, + new AdministrativeClass + { + Code = "CS2026-01", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026, + CounselorName = "陈老师" + }, + building, + new Classroom + { + Code = "J1-201", + Name = "J1-201", + BuildingId = building.Id, + Capacity = 60, + RoomType = "多媒体教室", + Equipment = "投影、扩声、录播" + }, + new AcademicTerm + { + Code = "2026-2027-1", + Name = "2026—2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 7), + EndDate = new DateOnly(2027, 1, 17), + IsCurrent = true + }); + + await db.SaveChangesAsync(); } - var campus = new Campus - { - Code = "MAIN", - Name = "主校区", - Address = "大学路 1 号" - }; - var college = new College - { - Code = "CS", - Name = "计算机学院", - ShortName = "计算机学院", - CampusId = campus.Id - }; - var major = new Major - { - Code = "080901", - Name = "计算机科学与技术", - CollegeId = college.Id, - DegreeType = "工学学士", - SchoolingYears = 4 - }; - var building = new Building - { - Code = "J1", - Name = "第一教学楼", - CampusId = campus.Id - }; + var computerCollege = await db.Colleges.SingleAsync(x => x.Code == "CS"); + var computerClass = await db.AdministrativeClasses + .SingleAsync(x => x.Code == "CS2026-01"); - db.AddRange( - campus, - college, - major, - new AdministrativeClass - { - Code = "CS2026-01", - Name = "计科 2026-1 班", - MajorId = major.Id, - Grade = 2026, - CounselorName = "陈老师" - }, - building, - new Classroom - { - Code = "J1-201", - Name = "J1-201", - BuildingId = building.Id, - Capacity = 60, - RoomType = "多媒体教室", - Equipment = "投影、扩声、录播" - }, - new AcademicTerm - { - Code = "2026-2027-1", - Name = "2026—2027 学年第一学期", - AcademicYear = "2026-2027", - Season = TermSeason.Autumn, - StartDate = new DateOnly(2026, 9, 7), - EndDate = new DateOnly(2027, 1, 17), - IsCurrent = true - }); + if (!await db.Teachers.AnyAsync()) + { + db.Teachers.AddRange( + new Teacher + { + TeacherNumber = "T2026001", + Name = "陈明远", + Gender = Gender.Male, + CollegeId = computerCollege.Id, + Title = "副教授", + Status = TeacherStatus.Active, + HireDate = new DateOnly(2018, 7, 1), + Email = "chenmy@example.edu.cn" + }, + new Teacher + { + TeacherNumber = "T2026002", + Name = "林书雅", + Gender = Gender.Female, + CollegeId = computerCollege.Id, + Title = "讲师", + Status = TeacherStatus.Active, + HireDate = new DateOnly(2022, 9, 1), + Email = "linsy@example.edu.cn" + }); + } + + if (!await db.Students.AnyAsync()) + { + db.Students.AddRange( + new Student + { + StudentNumber = "202601001", + Name = "周启航", + Gender = Gender.Male, + AdministrativeClassId = computerClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 7), + Status = StudentStatus.Active + }, + new Student + { + StudentNumber = "202601002", + Name = "许知夏", + Gender = Gender.Female, + AdministrativeClassId = computerClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 7), + Status = StudentStatus.Active + }, + new Student + { + StudentNumber = "202601003", + Name = "方嘉树", + Gender = Gender.Male, + AdministrativeClassId = computerClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 7), + Status = StudentStatus.Active + }); + } + + if (!await db.Courses.AnyAsync()) + { + db.Courses.AddRange( + new Course + { + Code = "CS101", + Name = "程序设计基础", + EnglishName = "Fundamentals of Programming", + CollegeId = computerCollege.Id, + Credits = 4, + TotalHours = 64, + LectureHours = 40, + PracticeHours = 24, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination, + Description = "面向一年级学生的程序设计入门课程。" + }, + new Course + { + Code = "CS201", + Name = "数据结构", + EnglishName = "Data Structures", + CollegeId = computerCollege.Id, + Credits = 3.5m, + TotalHours = 56, + LectureHours = 40, + PracticeHours = 16, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }, + new Course + { + Code = "CS305", + Name = "软件工程实践", + EnglishName = "Software Engineering Practice", + CollegeId = computerCollege.Id, + Credits = 2, + TotalHours = 48, + LectureHours = 8, + PracticeHours = 40, + Nature = CourseNature.Practice, + AssessmentMethod = AssessmentMethod.Assessment + }); + } await db.SaveChangesAsync(); } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs new file mode 100644 index 0000000..9298077 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -0,0 +1,169 @@ +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Persistence; + +public sealed class DevelopmentSqliteMigrator( + AppDbContext db, + ILogger logger) +{ + private const string PeopleAndCoursesMigration = "20260724_01_people_courses"; + + public async Task MigrateAsync(CancellationToken cancellationToken = default) + { + if (!db.Database.IsSqlite()) + { + return; + } + + await db.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "__DevelopmentSchemaHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___DevelopmentSchemaHistory" PRIMARY KEY, + "AppliedAt" TEXT NOT NULL + ); + """, + cancellationToken); + + var applied = await db.Database + .SqlQuery( + $""" + SELECT "MigrationId" AS "Value" + FROM "__DevelopmentSchemaHistory" + WHERE "MigrationId" = {PeopleAndCoursesMigration} + """) + .AnyAsync(cancellationToken); + if (applied) + { + return; + } + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + foreach (var statement in PeopleAndCoursesStatements) + { + await db.Database.ExecuteSqlRawAsync(statement, cancellationToken); + } + + await db.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "__DevelopmentSchemaHistory" ("MigrationId", "AppliedAt") + VALUES ({PeopleAndCoursesMigration}, {DateTime.UtcNow}); + """, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + logger.LogInformation( + "Applied SQLite development schema migration {MigrationId}.", + PeopleAndCoursesMigration); + } + + private static readonly string[] PeopleAndCoursesStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "Teachers" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Teachers" PRIMARY KEY, + "TeacherNumber" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Gender" INTEGER NOT NULL, + "CollegeId" TEXT NOT NULL, + "Title" TEXT NULL, + "Status" INTEGER NOT NULL, + "HireDate" TEXT NULL, + "IsExternal" INTEGER NOT NULL, + "Phone" TEXT NULL, + "Email" TEXT NULL, + "Notes" TEXT NULL, + "UserId" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_Teachers_Colleges_CollegeId" + FOREIGN KEY ("CollegeId") REFERENCES "Colleges" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_Teachers_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Teachers_TeacherNumber" + ON "Teachers" ("TeacherNumber"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Teachers_CollegeId_Status" + ON "Teachers" ("CollegeId", "Status"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Teachers_UserId" + ON "Teachers" ("UserId"); + """, + """ + CREATE TABLE IF NOT EXISTS "Students" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Students" PRIMARY KEY, + "StudentNumber" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Gender" INTEGER NOT NULL, + "AdministrativeClassId" TEXT NOT NULL, + "EnrollmentYear" INTEGER NOT NULL, + "EnrollmentDate" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "DateOfBirth" TEXT NULL, + "Phone" TEXT NULL, + "Email" TEXT NULL, + "Notes" TEXT NULL, + "UserId" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_Students_AdministrativeClasses_AdministrativeClassId" + FOREIGN KEY ("AdministrativeClassId") REFERENCES "AdministrativeClasses" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_Students_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Students_StudentNumber" + ON "Students" ("StudentNumber"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Students_AdministrativeClassId_Status" + ON "Students" ("AdministrativeClassId", "Status"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Students_EnrollmentYear" + ON "Students" ("EnrollmentYear"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Students_UserId" + ON "Students" ("UserId"); + """, + """ + CREATE TABLE IF NOT EXISTS "Courses" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Courses" PRIMARY KEY, + "CollegeId" TEXT NOT NULL, + "EnglishName" TEXT NULL, + "Credits" TEXT NOT NULL, + "TotalHours" INTEGER NOT NULL, + "LectureHours" INTEGER NOT NULL, + "PracticeHours" INTEGER NOT NULL, + "Nature" INTEGER NOT NULL, + "AssessmentMethod" INTEGER NOT NULL, + "Description" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + "Code" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + "IsEnabled" INTEGER NOT NULL, + CONSTRAINT "FK_Courses_Colleges_CollegeId" + FOREIGN KEY ("CollegeId") REFERENCES "Colleges" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Courses_Code" ON "Courses" ("Code"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Courses_IsEnabled_SortOrder" + ON "Courses" ("IsEnabled", "SortOrder"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_Courses_CollegeId_Nature" + ON "Courses" ("CollegeId", "Nature"); + """ + ]; +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.Designer.cs new file mode 100644 index 0000000..65c8ed0 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.Designer.cs @@ -0,0 +1,984 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260724045307_PeopleAndCourses")] + partial class PeopleAndCourses + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.cs new file mode 100644 index 0000000..02b842f --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724045307_PeopleAndCourses.cs @@ -0,0 +1,190 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class PeopleAndCourses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Courses", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + CollegeId = table.Column(type: "char(36)", nullable: false), + EnglishName = table.Column(type: "varchar(150)", maxLength: 150, nullable: true), + Credits = table.Column(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false), + TotalHours = table.Column(type: "int", nullable: false), + LectureHours = table.Column(type: "int", nullable: false), + PracticeHours = table.Column(type: "int", nullable: false), + Nature = table.Column(type: "int", nullable: false), + AssessmentMethod = table.Column(type: "int", nullable: false), + Description = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false), + Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Courses", x => x.Id); + table.ForeignKey( + name: "FK_Courses_Colleges_CollegeId", + column: x => x.CollegeId, + principalTable: "Colleges", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Students", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + StudentNumber = table.Column(type: "varchar(30)", maxLength: 30, nullable: false), + Name = table.Column(type: "varchar(50)", maxLength: 50, nullable: false), + Gender = table.Column(type: "int", nullable: false), + AdministrativeClassId = table.Column(type: "char(36)", nullable: false), + EnrollmentYear = table.Column(type: "int", nullable: false), + EnrollmentDate = table.Column(type: "date", nullable: false), + Status = table.Column(type: "int", nullable: false), + DateOfBirth = table.Column(type: "date", nullable: true), + Phone = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), + Email = table.Column(type: "varchar(100)", maxLength: 100, nullable: true), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + UserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Students", x => x.Id); + table.ForeignKey( + name: "FK_Students_AdministrativeClasses_AdministrativeClassId", + column: x => x.AdministrativeClassId, + principalTable: "AdministrativeClasses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Students_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Teachers", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + TeacherNumber = table.Column(type: "varchar(30)", maxLength: 30, nullable: false), + Name = table.Column(type: "varchar(50)", maxLength: 50, nullable: false), + Gender = table.Column(type: "int", nullable: false), + CollegeId = table.Column(type: "char(36)", nullable: false), + Title = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), + Status = table.Column(type: "int", nullable: false), + HireDate = table.Column(type: "date", nullable: true), + IsExternal = table.Column(type: "tinyint(1)", nullable: false), + Phone = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), + Email = table.Column(type: "varchar(100)", maxLength: 100, nullable: true), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + UserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Teachers", x => x.Id); + table.ForeignKey( + name: "FK_Teachers_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Teachers_Colleges_CollegeId", + column: x => x.CollegeId, + principalTable: "Colleges", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Courses_Code", + table: "Courses", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Courses_CollegeId_Nature", + table: "Courses", + columns: new[] { "CollegeId", "Nature" }); + + migrationBuilder.CreateIndex( + name: "IX_Courses_IsEnabled_SortOrder", + table: "Courses", + columns: new[] { "IsEnabled", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_Students_AdministrativeClassId_Status", + table: "Students", + columns: new[] { "AdministrativeClassId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Students_EnrollmentYear", + table: "Students", + column: "EnrollmentYear"); + + migrationBuilder.CreateIndex( + name: "IX_Students_StudentNumber", + table: "Students", + column: "StudentNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Students_UserId", + table: "Students", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_CollegeId_Status", + table: "Teachers", + columns: new[] { "CollegeId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_TeacherNumber", + table: "Teachers", + column: "TeacherNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_UserId", + table: "Teachers", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Courses"); + + migrationBuilder.DropTable( + name: "Students"); + + migrationBuilder.DropTable( + name: "Teachers"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index d314e2d..ca54a64 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -72,7 +72,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("AcademicTerms"); + b.ToTable("AcademicTerms", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => @@ -121,7 +121,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("AdministrativeClasses"); + b.ToTable("AdministrativeClasses", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => @@ -164,7 +164,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("Buildings"); + b.ToTable("Buildings", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => @@ -205,7 +205,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("Campuses"); + b.ToTable("Campuses", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => @@ -258,7 +258,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("Classrooms"); + b.ToTable("Classrooms", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => @@ -304,7 +304,77 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("Colleges"); + b.ToTable("Colleges", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => @@ -354,7 +424,144 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("IsEnabled", "SortOrder"); - b.ToTable("Majors"); + b.ToTable("Majors", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers", (string)null); }); modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => @@ -519,7 +726,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("CreatedAt"); - b.ToTable("AuditLogs"); + b.ToTable("AuditLogs", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -664,6 +871,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Campus"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => { b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") @@ -675,6 +893,38 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("College"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 373d26d..0771551 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -83,6 +83,7 @@ builder.Services.Configure( builder.Configuration.GetSection(JwtOptions.SectionName)); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/tests/Jiaowu.Api.Tests/PersistenceTests.cs b/tests/Jiaowu.Api.Tests/PersistenceTests.cs index 4def75d..272d939 100644 --- a/tests/Jiaowu.Api.Tests/PersistenceTests.cs +++ b/tests/Jiaowu.Api.Tests/PersistenceTests.cs @@ -64,4 +64,76 @@ public sealed class PersistenceTests : IAsyncLifetime Assert.Equal("东校区", saved.College!.Campus!.Name); } + + [Fact] + public async Task People_and_course_relations_can_be_persisted() + { + var campus = new Campus { Code = "MAIN", Name = "主校区" }; + var college = new College + { + Code = "CS", + Name = "计算机学院", + CampusId = campus.Id + }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士" + }; + var administrativeClass = new AdministrativeClass + { + Code = "CS2026-01", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; + _db.AddRange( + campus, + college, + major, + administrativeClass, + new Teacher + { + TeacherNumber = "T2026001", + Name = "陈老师", + CollegeId = college.Id, + Status = TeacherStatus.Active + }, + new Student + { + StudentNumber = "202601001", + Name = "周同学", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 7), + Status = StudentStatus.Active + }, + new Course + { + Code = "CS101", + Name = "程序设计基础", + CollegeId = college.Id, + Credits = 4, + TotalHours = 64, + LectureHours = 40, + PracticeHours = 24, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }); + await _db.SaveChangesAsync(); + + var teacher = await _db.Teachers.Include(x => x.College).SingleAsync(); + var student = await _db.Students + .Include(x => x.AdministrativeClass) + .ThenInclude(x => x!.Major) + .SingleAsync(); + var course = await _db.Courses.Include(x => x.College).SingleAsync(); + + Assert.Equal("计算机学院", teacher.College!.Name); + Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name); + Assert.Equal(4m, course.Credits); + Assert.Equal(course.TotalHours, course.LectureHours + course.PracticeHours); + } } diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 2f82aed..fe47c6b 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -24,6 +24,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index f87b159..723b806 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -1,7 +1,14 @@ + + diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index cddc5ac..58afddb 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -1,7 +1,7 @@ + +