From bcc4d33bd505e8ac5200cf6ad596d11439a804ac Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 14:54:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E8=A7=92=E8=89=B2=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=8C=89=20All=20>=20College=20>=20Class=20>=20Self=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=96=E6=9C=80=E9=AB=98=E6=9D=83=E9=99=90?= =?UTF-8?q?=E3=80=82=20=E5=AD=A6=E9=99=A2=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E9=99=90=E5=88=B6=E5=9C=A8=E6=89=80=E5=B1=9E=E5=AD=A6=E9=99=A2?= =?UTF-8?q?=E3=80=82=20=E8=BE=85=E5=AF=BC=E5=91=98=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E8=B4=A6=E5=8F=B7=20ID=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=A1=8C=E6=94=BF=E7=8F=AD=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D?= =?UTF-8?q?=E5=90=8D=E4=B8=B2=E7=8F=AD=E3=80=82=20=E6=95=99=E5=B8=88?= =?UTF-8?q?=E5=8F=AA=E8=83=BD=E8=AE=BF=E9=97=AE=E6=9C=AC=E4=BA=BA=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E3=80=81=E6=8E=88=E8=AF=BE=E8=AF=BE=E7=A8=8B=E5=92=8C?= =?UTF-8?q?=E6=89=80=E6=8E=88=E8=AF=BE=E5=AD=A6=E7=94=9F=E3=80=82=20?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E5=8F=AA=E8=83=BD=E8=AE=BF=E9=97=AE=E6=9C=AC?= =?UTF-8?q?=E4=BA=BA=E6=A1=A3=E6=A1=88=E5=8F=8A=E6=89=80=E5=9C=A8=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E8=AF=BE=E7=A8=8B=E3=80=82=20=E6=95=99=E5=B8=88/?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E8=A7=92=E8=89=B2=E4=BC=9A=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=B9=B6=E7=BB=91=E5=AE=9A=E5=B7=A5=E5=8F=B7?= =?UTF-8?q?=E6=88=96=E5=AD=A6=E5=8F=B7=E6=A1=A3=E6=A1=88=E3=80=82=20?= =?UTF-8?q?=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AF=E5=9C=A8?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=A1=B5=E9=9D=A2=E8=B0=83=E6=95=B4=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E3=80=81=E5=AD=A6=E9=99=A2=E3=80=81=E5=B7=A5=E5=8F=B7?= =?UTF-8?q?/=E5=AD=A6=E5=8F=B7=EF=BC=8C=E5=B9=B6=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=9F=E6=95=88=E5=90=8E=E7=9A=84=E6=95=B0=E6=8D=AE=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- scripts/smoke-test.ps1 | 83 + src/Jiaowu.Api/Controllers/AuthController.cs | 12 +- .../Controllers/BaseDataController.cs | 60 +- .../Controllers/CoursesController.cs | 45 +- .../Controllers/CurriculumPlansController.cs | 13 +- .../Controllers/PersonnelController.cs | 72 +- .../Controllers/TeachingTasksController.cs | 13 +- src/Jiaowu.Api/Controllers/UsersController.cs | 126 +- .../Domain/Academic/OrganizationEntities.cs | 3 + .../Auth/CurrentUserDataScope.cs | 89 + .../Infrastructure/Auth/TokenService.cs | 3 +- .../Persistence/AppDbContext.cs | 8 + .../Persistence/DatabaseInitializer.cs | 87 + .../Persistence/DevelopmentSqliteMigrator.cs | 27 + ...65023_ClassCounselorAssignment.Designer.cs | 1483 +++++++++++++++++ ...20260724065023_ClassCounselorAssignment.cs | 50 + .../MySql/AppDbContextModelSnapshot.cs | 12 + src/Jiaowu.Api/Program.cs | 2 + tests/Jiaowu.Api.Tests/DataScopeTests.cs | 56 + tests/Jiaowu.Api.Tests/PersistenceTests.cs | 17 +- web/src/components.d.ts | 1 + web/src/stores/auth.ts | 1 + web/src/style.css | 8 + web/src/views/BaseDataView.vue | 23 +- web/src/views/UsersView.vue | 113 +- 26 files changed, 2333 insertions(+), 88 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Auth/CurrentUserDataScope.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.cs create mode 100644 tests/Jiaowu.Api.Tests/DataScopeTests.cs diff --git a/README.md b/README.md index 9033aca..d6df247 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。 +权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 + ## 本地开发:热更新模式 本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。 @@ -20,7 +22,17 @@ npm install npm run dev ``` -访问 `http://localhost:5173`,开发账号为 `admin`,密码为 `Admin@123456`。 +访问 `http://localhost:5173`。本地开发会自动创建以下分级权限账号: + +| 数据范围 | 账号 | 密码 | +| --- | --- | --- | +| 超级管理员 | `admin` | `Admin@123456` | +| 校级教务 | `academic` | `Academic@123456` | +| 学院教务 | `college` | `College@123456` | +| 所带班级 | `counselor` | `Counselor@123456` | +| 教师本人 | `teacher` | `Teacher@123456` | +| 学生本人 | `student` | `Student@123456` | +| 领导查看 | `leader` | `Leader@123456` | ## 本地开发:单服务模式 diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 376ae05..78bace0 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -66,6 +66,15 @@ 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 + $classes = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/classes' -Headers $headers + $counselors = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/counselors' -Headers $headers + $assignedClassCount = @($classes | Where-Object { + $null -ne $_.counselorUserId -and + @($counselors).id -contains $_.counselorUserId + }).Count + if ($assignedClassCount -lt 1) { + throw 'No administrative class is linked to a counselor account.' + } $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 @@ -82,6 +91,77 @@ try { -Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" ` -Headers $headers } + $managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers + $teacherAccount = @($managedUsers) | + Where-Object { $_.userName -eq 'teacher' } | + Select-Object -First 1 + if ($null -eq $teacherAccount) { + throw 'Development teacher account was not seeded.' + } + $teacherAccessBody = @{ + staffNumber = $teacherAccount.staffNumber + collegeId = $teacherAccount.collegeId + roles = @('Teacher') + } | ConvertTo-Json + Invoke-RestMethod ` + -Method Put ` + -Uri "http://localhost:5255/api/users/$($teacherAccount.id)/roles" ` + -Headers $headers ` + -ContentType 'application/json' ` + -Body $teacherAccessBody + + $scopeScenarios = @( + @{ + UserName = 'college'; Password = 'College@123456'; Scope = 'College' + Teachers = 2; Students = 3; Courses = 3 + }, + @{ + UserName = 'counselor'; Password = 'Counselor@123456'; Scope = 'Class' + Teachers = 1; Students = 3; Courses = 1 + }, + @{ + UserName = 'teacher'; Password = 'Teacher@123456'; Scope = 'Self' + Teachers = 1; Students = 3; Courses = 1 + }, + @{ + UserName = 'student'; Password = 'Student@123456'; Scope = 'Self' + Teachers = 0; Students = 1; Courses = 1 + } + ) + $scopeChecks = foreach ($scenario in $scopeScenarios) { + $scenarioLoginBody = @{ + userName = $scenario.UserName + password = $scenario.Password + } | ConvertTo-Json + $scenarioLogin = Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:5255/api/auth/login' ` + -ContentType 'application/json' ` + -Body $scenarioLoginBody + $scenarioHeaders = @{ Authorization = "Bearer $($scenarioLogin.token)" } + $scenarioTeachers = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' ` + -Headers $scenarioHeaders + $scenarioStudents = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' ` + -Headers $scenarioHeaders + $scenarioCourses = Invoke-RestMethod ` + -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' ` + -Headers $scenarioHeaders + if ($scenarioLogin.user.effectiveDataScope -ne $scenario.Scope -or + $scenarioTeachers.total -ne $scenario.Teachers -or + $scenarioStudents.total -ne $scenario.Students -or + $scenarioCourses.total -ne $scenario.Courses) { + throw ("Data-scope check failed for {0}: scope={1}, teachers={2}, students={3}, courses={4}." -f + $scenario.UserName, + $scenarioLogin.user.effectiveDataScope, + $scenarioTeachers.total, + $scenarioStudents.total, + $scenarioCourses.total) + } + "$($scenario.UserName):$($scenario.Scope)" + } + $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $unknownApiParameters = @{ @@ -97,6 +177,7 @@ try { User = $login.user.displayName Term = $dashboard.currentTerm.name Campuses = @($campuses).Count + CounselorAssignments = $assignedClassCount Teachers = $teachers.total Students = $students.total Courses = $courses.total @@ -105,6 +186,8 @@ try { TeachingTasks = $teachingTasks.total Schedules = @($schedulePlans).Count ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 } + AccessUpdate = $true + ScopeChecks = $scopeChecks -join ', ' StaticIndex = $frontend.Content.Contains('明序教务管理系统') SpaFallback = $spaFallback.StatusCode ApiNotFound = $unknownApi.StatusCode diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index cd81f77..97dc012 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -53,7 +53,8 @@ public sealed class AuthController( user.UserName!, user.DisplayName, roles, - user.CollegeId)); + user.CollegeId, + EffectiveDataScopeResolver.Resolve(roles).ToString())); } [Authorize] @@ -67,12 +68,14 @@ public sealed class AuthController( return Unauthorized(); } + var roles = await userManager.GetRolesAsync(user); return new CurrentUserResponse( user.Id, user.UserName!, user.DisplayName, - await userManager.GetRolesAsync(user), - user.CollegeId); + roles, + user.CollegeId, + EffectiveDataScopeResolver.Resolve(roles).ToString()); } } @@ -87,4 +90,5 @@ public sealed record CurrentUserResponse( string UserName, string DisplayName, IEnumerable Roles, - Guid? CollegeId); + Guid? CollegeId, + string EffectiveDataScope); diff --git a/src/Jiaowu.Api/Controllers/BaseDataController.cs b/src/Jiaowu.Api/Controllers/BaseDataController.cs index 9280a8d..e155aef 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataController.cs @@ -168,7 +168,29 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase x.Id, x.Code, x.Name, x.MajorId, MajorName = x.Major!.Name, CollegeName = x.Major.College!.Name, - x.Grade, x.CounselorName, x.IsEnabled, x.SortOrder + x.Grade, x.CounselorUserId, x.CounselorName, + x.IsEnabled, x.SortOrder + }) + .ToListAsync(cancellationToken)); + + [HttpGet("counselors")] + [Authorize(Roles = Administrators)] + public async Task> GetCounselors(CancellationToken cancellationToken) => + Ok(await db.Users.AsNoTracking() + .Where(user => + user.IsEnabled && + db.UserRoles.Any(userRole => + userRole.UserId == user.Id && + db.Roles.Any(role => + role.Id == userRole.RoleId && + role.Name == SystemRoles.Counselor))) + .OrderBy(x => x.DisplayName) + .Select(x => new + { + x.Id, + x.DisplayName, + x.StaffNumber, + x.CollegeId }) .ToListAsync(cancellationToken)); @@ -180,6 +202,11 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase { if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken)) return ValidationProblem("所选专业不存在。"); + var counselorName = await GetCounselorNameAsync( + request.CounselorUserId, + cancellationToken); + if (request.CounselorUserId.HasValue && counselorName is null) + return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。"); var entity = new AdministrativeClass { @@ -187,7 +214,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase Name = request.Name.Trim(), MajorId = request.MajorId, Grade = request.Grade, - CounselorName = request.CounselorName?.Trim(), + CounselorUserId = request.CounselorUserId, + CounselorName = counselorName, SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; @@ -203,10 +231,16 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase { var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); + var counselorName = await GetCounselorNameAsync( + request.CounselorUserId, + cancellationToken); + if (request.CounselorUserId.HasValue && counselorName is null) + return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。"); ApplyCatalog(entity, request); entity.MajorId = request.MajorId; entity.Grade = request.Grade; - entity.CounselorName = request.CounselorName?.Trim(); + entity.CounselorUserId = request.CounselorUserId; + entity.CounselorName = counselorName; await db.SaveChangesAsync(cancellationToken); return entity; } @@ -409,6 +443,24 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase return CreatedAtAction(action, new { id = entity.Id }, entity); } + private async Task GetCounselorNameAsync( + Guid? userId, + CancellationToken cancellationToken) + { + if (!userId.HasValue) return null; + return await db.Users.AsNoTracking() + .Where(user => + user.Id == userId && + user.IsEnabled && + db.UserRoles.Any(userRole => + userRole.UserId == user.Id && + db.Roles.Any(role => + role.Id == userRole.RoleId && + role.Name == SystemRoles.Counselor))) + .Select(x => x.DisplayName) + .FirstOrDefaultAsync(cancellationToken); + } + private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request) { entity.Code = request.Code.Trim(); @@ -442,7 +494,7 @@ public sealed record ClassRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid MajorId, [Range(2000, 2200)] int Grade, - [MaxLength(50)] string? CounselorName) + Guid? CounselorUserId) : CatalogRequest(Code, Name, SortOrder, IsEnabled); public sealed record TermRequest( diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index 5bf045f..74c7245 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/courses")] -public sealed class CoursesController(AppDbContext db) : ControllerBase +public sealed class CoursesController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string WriteRoles = SystemRoles.SuperAdmin + "," + @@ -32,10 +34,7 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); - var source = db.Courses.AsNoTracking().AsQueryable(); - var scopedCollegeId = GetScopedCollegeId(); - if (scopedCollegeId.HasValue) - source = source.Where(x => x.CollegeId == scopedCollegeId.Value); + var source = ScopedCourses().AsNoTracking(); if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value); if (nature.HasValue) @@ -162,17 +161,37 @@ public sealed class CoursesController(AppDbContext db) : ControllerBase return null; } - private Guid? GetScopedCollegeId() + private IQueryable ScopedCourses() { - if (!User.IsInRole(SystemRoles.CollegeAdmin)) - return null; - return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) - ? collegeId - : Guid.Empty; + var scope = currentUserDataScope.Current; + var source = db.Courses.AsQueryable(); + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); + if (scope.Scope == DataScope.Class) + { + return source.Where(course => db.TeachingTasks.Any(task => + task.CourseId == course.Id && + task.Classes.Any(item => + item.AdministrativeClass!.CounselorUserId == scope.UserId))); + } + + var userId = scope.UserId; + var isTeacher = scope.IsInRole(SystemRoles.Teacher); + var isStudent = scope.IsInRole(SystemRoles.Student); + return source.Where(course => + isTeacher && db.TeachingTasks.Any(task => + task.CourseId == course.Id && + task.Teachers.Any(item => item.Teacher!.UserId == userId)) || + isStudent && db.TeachingTasks.Any(task => + task.CourseId == course.Id && + task.Classes.Any(item => + item.AdministrativeClass!.Students.Any(student => + student.UserId == userId)))); } private bool CanAccessCollege(Guid collegeId) => - !GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId; + currentUserDataScope.Current.CanAccessCollege(collegeId); private async Task SaveAsync( Guid id, diff --git a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs index 896dba8..f448370 100644 --- a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs +++ b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ReadRoles)] [Route("api/curriculum-plans")] -public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase +public sealed class CurriculumPlansController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -447,12 +449,7 @@ public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase cancellationToken); private Guid? ScopedCollegeId() - { - if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null; - return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) - ? collegeId - : Guid.Empty; - } + => currentUserDataScope.Current.RestrictedCollegeId; private async Task SaveCreatedAsync(Guid id, CancellationToken cancellationToken) { diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index 34bb768..e47db5a 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,13 +13,17 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ReadRoles)] [Route("api/personnel")] -public sealed class PersonnelController(AppDbContext db) : ControllerBase +public sealed class PersonnelController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + - SystemRoles.Counselor; + SystemRoles.Counselor + "," + + SystemRoles.Teacher + "," + + SystemRoles.Student; private const string WriteRoles = SystemRoles.SuperAdmin + "," + @@ -33,10 +37,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase { var page = NormalizePage(query.Page); var pageSize = NormalizePageSize(query.PageSize); - var source = db.Teachers.AsNoTracking().AsQueryable(); - var scopedCollegeId = GetScopedCollegeId(); - if (scopedCollegeId.HasValue) - source = source.Where(x => x.CollegeId == scopedCollegeId.Value); + var source = ApplyTeacherScope(db.Teachers.AsNoTracking()); if (query.CollegeId.HasValue) source = source.Where(x => x.CollegeId == query.CollegeId); if (!string.IsNullOrWhiteSpace(query.Keyword)) @@ -155,13 +156,7 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase { var page = NormalizePage(query.Page); var pageSize = NormalizePageSize(query.PageSize); - var source = db.Students.AsNoTracking().AsQueryable(); - var scopedCollegeId = GetScopedCollegeId(); - if (scopedCollegeId.HasValue) - { - source = source.Where(x => - x.AdministrativeClass!.Major!.CollegeId == scopedCollegeId.Value); - } + var source = ApplyStudentScope(db.Students.AsNoTracking()); if (query.CollegeId.HasValue) { source = source.Where(x => @@ -303,21 +298,54 @@ public sealed class PersonnelController(AppDbContext db) : ControllerBase return await SaveNoContentAsync(cancellationToken); } - private Guid? GetScopedCollegeId() + private IQueryable ApplyTeacherScope(IQueryable source) { - if (!User.IsInRole(SystemRoles.CollegeAdmin) && - !User.IsInRole(SystemRoles.Counselor)) + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); + if (scope.Scope == DataScope.Class) { - return null; + return source.Where(teacher => db.TeachingTasks.Any(task => + task.Teachers.Any(item => item.TeacherId == teacher.Id) && + task.Classes.Any(item => + item.AdministrativeClass!.CounselorUserId == scope.UserId))); } - return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) - ? collegeId - : Guid.Empty; + var userId = scope.UserId; + return scope.IsInRole(SystemRoles.Teacher) + ? source.Where(x => x.UserId == userId) + : source.Where(_ => false); + } + + private IQueryable 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) => - !GetScopedCollegeId().HasValue || GetScopedCollegeId() == collegeId; + currentUserDataScope.Current.CanAccessCollege(collegeId); private async Task ValidateCollegeAsync( Guid collegeId, diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 579e24a..3cf190a 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +13,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ManagementRoles)] [Route("api/teaching-tasks")] -public sealed class TeachingTasksController(AppDbContext db) : ControllerBase +public sealed class TeachingTasksController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -314,12 +316,7 @@ public sealed class TeachingTasksController(AppDbContext db) : ControllerBase } private Guid? ScopedCollegeId() - { - if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null; - return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) - ? collegeId - : Guid.Empty; - } + => currentUserDataScope.Current.RestrictedCollegeId; private async Task SaveAsync( Guid id, diff --git a/src/Jiaowu.Api/Controllers/UsersController.cs b/src/Jiaowu.Api/Controllers/UsersController.cs index 49e3191..55be428 100644 --- a/src/Jiaowu.Api/Controllers/UsersController.cs +++ b/src/Jiaowu.Api/Controllers/UsersController.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; +using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,7 @@ namespace Jiaowu.Api.Controllers; [Authorize(Roles = SystemRoles.SuperAdmin)] [Route("api/users")] public sealed class UsersController( + AppDbContext db, UserManager userManager, RoleManager roleManager) : ControllerBase { @@ -53,29 +56,35 @@ public sealed class UsersController( [HttpPost] public async Task Create(CreateUserRequest request) { - var invalidRoles = request.Roles - .Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (invalidRoles.Length > 0) - return ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}"); + var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var invalidRoles = ValidateRoles(roles); + if (invalidRoles is not null) return invalidRoles; + var staffNumber = Normalize(request.StaffNumber); + var profiles = await ResolveProfilesAsync(staffNumber, roles); + if (profiles.Error is not null) return profiles.Error; var user = new ApplicationUser { UserName = request.UserName.Trim(), DisplayName = request.DisplayName.Trim(), - StaffNumber = request.StaffNumber?.Trim(), + StaffNumber = staffNumber, CollegeId = request.CollegeId, LockoutEnabled = true, IsEnabled = true }; + await using var transaction = await db.Database.BeginTransactionAsync(); var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded) return IdentityValidationProblem(result); - result = await userManager.AddToRolesAsync(user, request.Roles); + result = await userManager.AddToRolesAsync(user, roles); if (!result.Succeeded) return IdentityValidationProblem(result); + if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id; + if (profiles.Student is not null) profiles.Student.UserId = user.Id; + await db.SaveChangesAsync(); + await transaction.CommitAsync(); return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id }); } @@ -99,28 +108,97 @@ public sealed class UsersController( var user = await userManager.FindByIdAsync(id.ToString()); if (user is null) return NotFound(); - var invalidRoles = request.Roles - .Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (invalidRoles.Length > 0) - return ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}"); + var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var invalidRoles = ValidateRoles(roles); + if (invalidRoles is not null) return invalidRoles; + var staffNumber = Normalize(request.StaffNumber); + var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id); + if (profiles.Error is not null) return profiles.Error; var existing = await userManager.GetRolesAsync(user); if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && existing.Contains(SystemRoles.SuperAdmin) && - !request.Roles.Contains(SystemRoles.SuperAdmin)) + !roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) { return ValidationProblem("不能移除当前账号的超级管理员角色。"); } + await using var transaction = await db.Database.BeginTransactionAsync(); + user.StaffNumber = staffNumber; + user.CollegeId = request.CollegeId; + var updateResult = await userManager.UpdateAsync(user); + if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult); var removeResult = await userManager.RemoveFromRolesAsync( user, - existing.Except(request.Roles)); + existing.Except(roles, StringComparer.OrdinalIgnoreCase)); if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult); var addResult = await userManager.AddToRolesAsync( user, - request.Roles.Except(existing)); - return addResult.Succeeded ? NoContent() : IdentityValidationProblem(addResult); + roles.Except(existing, StringComparer.OrdinalIgnoreCase)); + if (!addResult.Succeeded) return IdentityValidationProblem(addResult); + + var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync(); + var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync(); + if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) + foreach (var teacher in linkedTeachers) teacher.UserId = null; + if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase)) + foreach (var student in linkedStudents) student.UserId = null; + if (profiles.Teacher is not null) profiles.Teacher.UserId = id; + if (profiles.Student is not null) profiles.Student.UserId = id; + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + return NoContent(); + } + + private ActionResult? ValidateRoles(IReadOnlyCollection roles) + { + if (roles.Count == 0) return ValidationProblem("至少需要分配一个角色。"); + var invalidRoles = roles + .Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return invalidRoles.Length == 0 + ? null + : ValidationProblem($"无效角色:{string.Join("、", invalidRoles)}"); + } + + private async Task<(Teacher? Teacher, Student? Student, ActionResult? Error)> + ResolveProfilesAsync( + string? staffNumber, + IReadOnlyCollection roles, + Guid? currentUserId = null) + { + var needsTeacher = roles.Contains( + SystemRoles.Teacher, + StringComparer.OrdinalIgnoreCase); + var needsStudent = roles.Contains( + SystemRoles.Student, + StringComparer.OrdinalIgnoreCase); + if (!needsTeacher && !needsStudent) return (null, null, null); + if (string.IsNullOrWhiteSpace(staffNumber)) + return (null, null, ValidationProblem("教师或学生角色必须填写对应的工号/学号。")); + + Teacher? teacher = null; + Student? student = null; + if (needsTeacher) + { + teacher = await db.Teachers.SingleOrDefaultAsync( + x => x.TeacherNumber == staffNumber); + if (teacher is null) + return (null, null, ValidationProblem("未找到与该工号对应的教师档案。")); + if (teacher.UserId.HasValue && teacher.UserId != currentUserId) + return (null, null, ConflictProblem("该教师档案已经关联其他账号。")); + } + if (needsStudent) + { + student = await db.Students.SingleOrDefaultAsync( + x => x.StudentNumber == staffNumber); + if (student is null) + return (null, null, ValidationProblem("未找到与该学号对应的学生档案。")); + if (student.UserId.HasValue && student.UserId != currentUserId) + return (null, null, ConflictProblem("该学生档案已经关联其他账号。")); + } + + return (teacher, student, null); } private ActionResult IdentityValidationProblem(IdentityResult result) @@ -129,6 +207,17 @@ public sealed class UsersController( ModelState.AddModelError(error.Code, error.Description); return ValidationProblem(ModelState); } + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "账号关联冲突", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public sealed record CreateUserRequest( @@ -140,4 +229,7 @@ public sealed record CreateUserRequest( [MinLength(1)] string[] Roles); public sealed record SetUserStatusRequest(bool IsEnabled); -public sealed record SetRolesRequest([MinLength(1)] string[] Roles); +public sealed record SetRolesRequest( + [MaxLength(30)] string? StaffNumber, + Guid? CollegeId, + [MinLength(1)] string[] Roles); diff --git a/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs index 3405e32..4db1687 100644 --- a/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/OrganizationEntities.cs @@ -1,4 +1,5 @@ using Jiaowu.Api.Domain.Common; +using Jiaowu.Api.Domain.Identity; namespace Jiaowu.Api.Domain.Academic; @@ -28,6 +29,8 @@ public sealed class AdministrativeClass : CatalogEntity public Major? Major { get; set; } public int Grade { get; set; } public string? CounselorName { get; set; } + public Guid? CounselorUserId { get; set; } + public ApplicationUser? CounselorUser { get; set; } public ICollection Students { get; set; } = []; } diff --git a/src/Jiaowu.Api/Infrastructure/Auth/CurrentUserDataScope.cs b/src/Jiaowu.Api/Infrastructure/Auth/CurrentUserDataScope.cs new file mode 100644 index 0000000..72ed841 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/CurrentUserDataScope.cs @@ -0,0 +1,89 @@ +using System.Security.Claims; +using Jiaowu.Api.Domain.Identity; + +namespace Jiaowu.Api.Infrastructure.Auth; + +public interface ICurrentUserDataScope +{ + CurrentUserScope Current { get; } +} + +public sealed record CurrentUserScope( + Guid UserId, + string? DisplayName, + Guid? CollegeId, + DataScope Scope, + IReadOnlySet Roles) +{ + public bool IsInRole(string role) => Roles.Contains(role); + + public bool CanAccessCollege(Guid collegeId) => + Scope == DataScope.All || + Scope == DataScope.College && CollegeId == collegeId; + + public Guid? RestrictedCollegeId => Scope switch + { + DataScope.All => null, + DataScope.College => CollegeId ?? Guid.Empty, + _ => Guid.Empty + }; +} + +public sealed class CurrentUserDataScope(IHttpContextAccessor httpContextAccessor) + : ICurrentUserDataScope +{ + public CurrentUserScope Current + { + get + { + var principal = httpContextAccessor.HttpContext?.User; + var roles = new HashSet( + principal?.FindAll(ClaimTypes.Role).Select(x => x.Value) ?? [], + StringComparer.OrdinalIgnoreCase); + + return new CurrentUserScope( + ParseGuid(principal?.FindFirstValue(ClaimTypes.NameIdentifier)), + principal?.FindFirstValue("display_name") ?? + principal?.FindFirstValue(ClaimTypes.Name), + ParseNullableGuid(principal?.FindFirstValue("college_id")), + EffectiveDataScopeResolver.Resolve(roles), + roles); + } + } + + private static Guid ParseGuid(string? value) => + Guid.TryParse(value, out var id) ? id : Guid.Empty; + + private static Guid? ParseNullableGuid(string? value) => + Guid.TryParse(value, out var id) ? id : null; +} + +public static class EffectiveDataScopeResolver +{ + private static readonly IReadOnlyDictionary RoleScopes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [SystemRoles.SuperAdmin] = DataScope.All, + [SystemRoles.AcademicAdmin] = DataScope.All, + [SystemRoles.Leader] = DataScope.All, + [SystemRoles.CollegeAdmin] = DataScope.College, + [SystemRoles.Counselor] = DataScope.Class, + [SystemRoles.Teacher] = DataScope.Self, + [SystemRoles.Student] = DataScope.Self + }; + + public static DataScope Resolve(IEnumerable roles) + { + var effectiveScope = DataScope.Self; + foreach (var role in roles) + { + if (RoleScopes.TryGetValue(role, out var roleScope) && + roleScope > effectiveScope) + { + effectiveScope = roleScope; + } + } + + return effectiveScope; + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs b/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs index 1b78f54..66dd00e 100644 --- a/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs +++ b/src/Jiaowu.Api/Infrastructure/Auth/TokenService.cs @@ -23,7 +23,8 @@ public sealed class TokenService(IOptions options) : ITokenService new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.UniqueName, user.UserName ?? string.Empty), new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(ClaimTypes.Name, user.DisplayName) + new(ClaimTypes.Name, user.DisplayName), + new("display_name", user.DisplayName) }; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index c51504c..514d05a 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -72,6 +72,14 @@ public sealed class AppDbContext(DbContextOptions options) .WithMany() .HasForeignKey(x => x.MajorId) .OnDelete(DeleteBehavior.Restrict); + builder.Entity(entity => + { + entity.HasIndex(x => x.CounselorUserId); + entity.HasOne(x => x.CounselorUser) + .WithMany() + .HasForeignKey(x => x.CounselorUserId) + .OnDelete(DeleteBehavior.SetNull); + }); builder.Entity() .HasOne(x => x.Campus) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index 6978d5c..9c6d5c0 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -419,6 +419,85 @@ public sealed class DatabaseInitializer( }); await db.SaveChangesAsync(); } + + await SeedDevelopmentUsersAsync(computerCollege.Id); + } + + private async Task SeedDevelopmentUsersAsync(Guid collegeId) + { + var definitions = new[] + { + new DevelopmentUser( + "academic", "校级教务员", "Academic@123456", + null, null, SystemRoles.AcademicAdmin), + new DevelopmentUser( + "college", "计算机学院教务员", "College@123456", + "A2026001", collegeId, SystemRoles.CollegeAdmin), + new DevelopmentUser( + "counselor", "陈老师", "Counselor@123456", + "C2026001", collegeId, SystemRoles.Counselor), + new DevelopmentUser( + "teacher", "陈明远", "Teacher@123456", + "T2026001", collegeId, SystemRoles.Teacher), + new DevelopmentUser( + "student", "周启航", "Student@123456", + "202601001", collegeId, SystemRoles.Student), + new DevelopmentUser( + "leader", "教学分管领导", "Leader@123456", + null, null, SystemRoles.Leader) + }; + + foreach (var definition in definitions) + { + var user = await userManager.FindByNameAsync(definition.UserName); + if (user is null) + { + user = new ApplicationUser + { + UserName = definition.UserName, + DisplayName = definition.DisplayName, + StaffNumber = definition.StaffNumber, + CollegeId = definition.CollegeId, + LockoutEnabled = true, + IsEnabled = true + }; + EnsureSucceeded( + await userManager.CreateAsync(user, definition.Password), + $"创建开发账号 {definition.UserName}"); + } + + if (!await userManager.IsInRoleAsync(user, definition.Role)) + { + EnsureSucceeded( + await userManager.AddToRoleAsync(user, definition.Role), + $"授予开发账号 {definition.UserName} 角色"); + } + + if (definition.Role == SystemRoles.Teacher) + { + var teacher = await db.Teachers.SingleAsync( + x => x.TeacherNumber == definition.StaffNumber); + if (!teacher.UserId.HasValue) teacher.UserId = user.Id; + } + if (definition.Role == SystemRoles.Student) + { + var student = await db.Students.SingleAsync( + x => x.StudentNumber == definition.StaffNumber); + if (!student.UserId.HasValue) student.UserId = user.Id; + } + if (definition.Role == SystemRoles.Counselor) + { + var classes = await db.AdministrativeClasses + .Where(x => + x.CounselorUserId == null && + x.CounselorName == definition.DisplayName) + .ToListAsync(); + foreach (var administrativeClass in classes) + administrativeClass.CounselorUserId = user.Id; + } + } + + await db.SaveChangesAsync(); } private static void EnsureSucceeded(IdentityResult result, string action) @@ -431,4 +510,12 @@ public sealed class DatabaseInitializer( throw new InvalidOperationException( $"{action}失败:{string.Join(";", result.Errors.Select(x => x.Description))}"); } + + private sealed record DevelopmentUser( + string UserName, + string DisplayName, + string Password, + string? StaffNumber, + Guid? CollegeId, + string Role); } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 84dfe09..7c2e113 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -10,6 +10,7 @@ public sealed class DevelopmentSqliteMigrator( private const string CurriculumPlansMigration = "20260724_02_curriculum_plans"; private const string TeachingTasksMigration = "20260724_03_teaching_tasks"; private const string SchedulesMigration = "20260724_04_schedules"; + private const string ClassCounselorMigration = "20260724_05_class_counselor"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -43,6 +44,20 @@ public sealed class DevelopmentSqliteMigrator( SchedulesMigration, SchedulesStatements, cancellationToken); + var counselorColumnExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('AdministrativeClasses') + WHERE name = 'CounselorUserId' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + ClassCounselorMigration, + counselorColumnExists + ? ClassCounselorStatements.Skip(1) + : ClassCounselorStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -399,4 +414,16 @@ public sealed class DevelopmentSqliteMigrator( ON "ScheduleEntries" ("ClassroomId"); """ ]; + + private static readonly string[] ClassCounselorStatements = + [ + """ + ALTER TABLE "AdministrativeClasses" + ADD COLUMN "CounselorUserId" TEXT NULL; + """, + """ + CREATE INDEX IF NOT EXISTS "IX_AdministrativeClasses_CounselorUserId" + ON "AdministrativeClasses" ("CounselorUserId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.Designer.cs new file mode 100644 index 0000000..5954bb6 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.Designer.cs @@ -0,0 +1,1483 @@ +// +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("20260724065023_ClassCounselorAssignment")] + partial class ClassCounselorAssignment + { + /// + 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("CounselorUserId") + .HasColumnType("char(36)"); + + 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("CounselorUserId"); + + 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.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + 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.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + 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.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + 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.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + 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.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", 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.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.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .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("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + 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(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.cs new file mode 100644 index 0000000..e5b6cb9 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724065023_ClassCounselorAssignment.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class ClassCounselorAssignment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CounselorUserId", + table: "AdministrativeClasses", + type: "char(36)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AdministrativeClasses_CounselorUserId", + table: "AdministrativeClasses", + column: "CounselorUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId", + table: "AdministrativeClasses", + column: "CounselorUserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AdministrativeClasses_AspNetUsers_CounselorUserId", + table: "AdministrativeClasses"); + + migrationBuilder.DropIndex( + name: "IX_AdministrativeClasses_CounselorUserId", + table: "AdministrativeClasses"); + + migrationBuilder.DropColumn( + name: "CounselorUserId", + table: "AdministrativeClasses"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 440075e..91a1feb 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -89,6 +89,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("CounselorName") .HasColumnType("longtext"); + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + b.Property("CreatedAt") .HasColumnType("datetime(6)"); @@ -117,6 +120,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("Code") .IsUnique(); + b.HasIndex("CounselorUserId"); + b.HasIndex("MajorId"); b.HasIndex("IsEnabled", "SortOrder"); @@ -1154,12 +1159,19 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") .WithMany() .HasForeignKey("MajorId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("CounselorUser"); + b.Navigation("Major"); }); diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 0771551..be62bac 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -81,7 +81,9 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32) builder.Services.Configure( builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Jiaowu.Api.Tests/DataScopeTests.cs b/tests/Jiaowu.Api.Tests/DataScopeTests.cs new file mode 100644 index 0000000..5a2acaf --- /dev/null +++ b/tests/Jiaowu.Api.Tests/DataScopeTests.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Microsoft.AspNetCore.Http; + +namespace Jiaowu.Api.Tests; + +public sealed class DataScopeTests +{ + [Theory] + [InlineData(SystemRoles.Student, null, DataScope.Self)] + [InlineData(SystemRoles.Counselor, SystemRoles.Teacher, DataScope.Class)] + [InlineData(SystemRoles.CollegeAdmin, SystemRoles.Counselor, DataScope.College)] + [InlineData(SystemRoles.SuperAdmin, SystemRoles.CollegeAdmin, DataScope.All)] + [InlineData(SystemRoles.AcademicAdmin, SystemRoles.Student, DataScope.All)] + public void Resolver_UsesHighestScope( + string firstRole, + string? secondRole, + DataScope expected) + { + var roles = secondRole is null + ? [firstRole] + : new[] { firstRole, secondRole }; + + Assert.Equal(expected, EffectiveDataScopeResolver.Resolve(roles)); + } + + [Fact] + public void CurrentScope_ReadsIdentityAndCollegeClaims() + { + var userId = Guid.NewGuid(); + var collegeId = Guid.NewGuid(); + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Name, "院系管理员"), + new Claim(ClaimTypes.Role, SystemRoles.CollegeAdmin), + new Claim("college_id", collegeId.ToString()) + }; + var accessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")) + } + }; + + var current = new CurrentUserDataScope(accessor).Current; + + Assert.Equal(userId, current.UserId); + Assert.Equal(collegeId, current.CollegeId); + Assert.Equal(DataScope.College, current.Scope); + Assert.True(current.CanAccessCollege(collegeId)); + Assert.False(current.CanAccessCollege(Guid.NewGuid())); + } +} diff --git a/tests/Jiaowu.Api.Tests/PersistenceTests.cs b/tests/Jiaowu.Api.Tests/PersistenceTests.cs index 3ecb23d..e105522 100644 --- a/tests/Jiaowu.Api.Tests/PersistenceTests.cs +++ b/tests/Jiaowu.Api.Tests/PersistenceTests.cs @@ -1,4 +1,5 @@ using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -82,12 +83,21 @@ public sealed class PersistenceTests : IAsyncLifetime CollegeId = college.Id, DegreeType = "工学学士" }; + var counselor = new ApplicationUser + { + UserName = "counselor", + NormalizedUserName = "COUNSELOR", + DisplayName = "陈老师" + }; var administrativeClass = new AdministrativeClass { Code = "CS2026-01", Name = "计科 2026-1 班", MajorId = major.Id, - Grade = 2026 + Grade = 2026, + CounselorName = counselor.DisplayName, + CounselorUserId = counselor.Id, + CounselorUser = counselor }; var course = new Course { @@ -135,6 +145,7 @@ public sealed class PersistenceTests : IAsyncLifetime campus, college, major, + counselor, administrativeClass, teacher, new Student @@ -223,6 +234,9 @@ public sealed class PersistenceTests : IAsyncLifetime .Include(x => x.AdministrativeClass) .ThenInclude(x => x!.Major) .SingleAsync(); + var savedClass = await _db.AdministrativeClasses + .Include(x => x.CounselorUser) + .SingleAsync(); var savedCourse = await _db.Courses.Include(x => x.College).SingleAsync(); var curriculumPlan = await _db.CurriculumPlans .Include(x => x.Modules) @@ -239,6 +253,7 @@ public sealed class PersistenceTests : IAsyncLifetime Assert.Equal("计算机学院", savedTeacher.College!.Name); Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name); + Assert.Equal("陈老师", savedClass.CounselorUser!.DisplayName); Assert.Equal(4m, savedCourse.Credits); Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours); Assert.Single(curriculumPlan.Modules); diff --git a/web/src/components.d.ts b/web/src/components.d.ts index fe47c6b..7540c81 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -11,6 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index c5f1aa9..662f608 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -8,6 +8,7 @@ export interface CurrentUser { displayName: string roles: string[] collegeId?: string + effectiveDataScope: 'Self' | 'Class' | 'College' | 'All' } export const useAuthStore = defineStore('auth', () => { diff --git a/web/src/style.css b/web/src/style.css index 32e432e..db9d4f0 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -124,6 +124,13 @@ button { cursor: pointer; } .table-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .table-status.off { color: #a0a6b1; } .role-tag { margin: 2px 4px 2px 0; } +.role-editor-summary { + display: flex; align-items: baseline; justify-content: space-between; gap: 16px; + margin: -4px 0 20px; padding: 14px 16px; + border-left: 3px solid var(--teal); background: #f5f7fb; +} +.role-editor-summary strong { color: var(--ink); font-size: 17px; } +.role-editor-summary span { color: var(--muted); font-size: 13px; } .entity-form .el-select, .entity-form .el-date-editor { width: 100%; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid.compact { align-items: center; } @@ -332,6 +339,7 @@ button { cursor: pointer; } .schedule-search { flex-wrap: wrap; } .schedule-search .el-input { width: 100%; } .schedule-search > span { width: 100%; margin-left: 0; } + .role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; } .form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; } .el-dialog { width: calc(100vw - 24px) !important; } .login-page { display: block; min-height: 100vh; background: #f4f6f9; } diff --git a/web/src/views/BaseDataView.vue b/web/src/views/BaseDataView.vue index efe8637..c8ecaf2 100644 --- a/web/src/views/BaseDataView.vue +++ b/web/src/views/BaseDataView.vue @@ -24,8 +24,8 @@ const loading = ref(false) const dialogVisible = ref(false) const editingId = ref('') const keyword = ref('') -const references = reactive>({ - campuses: [], colleges: [], majors: [], buildings: [], +const references = reactive>({ + campuses: [], colleges: [], majors: [], buildings: [], counselors: [], }) const form = reactive>({}) @@ -48,7 +48,7 @@ function resetForm(row?: Row) { code: '', name: '', sortOrder: 0, isEnabled: true, campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined, shortName: '', degreeType: '学士', schoolingYears: 4, - grade: new Date().getFullYear(), counselorName: '', + grade: new Date().getFullYear(), counselorUserId: undefined, academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`, season: 'Autumn', startDate: '', endDate: '', isCurrent: false, capacity: 60, roomType: '普通教室', equipment: '', @@ -71,6 +71,9 @@ async function loadReferences() { await Promise.all(kinds.map(async (kind) => { references[kind] = (await http.get(`/base-data/${kind}`)).data })) + if (canManage.value) { + references.counselors = (await http.get('/base-data/counselors')).data + } } async function changeTab() { @@ -168,6 +171,9 @@ onMounted(async () => { + + + @@ -221,7 +227,16 @@ onMounted(async () => {
- + + + + +