diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index e47db5a..f48e211 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + UserManager userManager) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -149,6 +151,61 @@ public sealed class PersonnelController( return await SaveNoContentAsync(cancellationToken); } + [HttpPost("teachers/{id:guid}/activate-account")] + [Authorize(Roles = WriteRoles)] + public async Task ActivateTeacherAccount( + Guid id, + TeacherAccountActivationRequest request, + CancellationToken cancellationToken) + { + var teacher = await db.Teachers.FindAsync([id], cancellationToken); + if (teacher is null) return NotFound(); + if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); + if (teacher.Status != TeacherStatus.Active) + { + return ConflictProblem("仅在职教师可以激活登录账号。"); + } + if (teacher.UserId.HasValue) + { + return ConflictProblem("该教师档案已经关联登录账号。"); + } + + var userName = teacher.TeacherNumber.Trim(); + if (await userManager.FindByNameAsync(userName) is not null) + { + return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。"); + } + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + var user = new ApplicationUser + { + UserName = userName, + DisplayName = teacher.Name, + StaffNumber = userName, + CollegeId = teacher.CollegeId, + IsEnabled = true, + LockoutEnabled = true + }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } + + result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } + + teacher.UserId = user.Id; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { user.Id, UserName = userName }); + } + [HttpGet("students")] public async Task>> GetStudents( [FromQuery] PersonnelQuery query, @@ -393,6 +450,13 @@ public sealed class PersonnelController( Status = StatusCodes.Status409Conflict }); + private ActionResult IdentityValidationProblem(IdentityResult result) + { + foreach (var error in result.Errors) + ModelState.AddModelError(error.Code, error.Description); + return ValidationProblem(ModelState); + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -436,3 +500,6 @@ public sealed record StudentRequest( [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, [MaxLength(500)] string? Notes); + +public sealed record TeacherAccountActivationRequest( + [Required, MinLength(8), MaxLength(100)] string Password); diff --git a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs new file mode 100644 index 0000000..777e619 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs @@ -0,0 +1,90 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class PersonnelControllerTests +{ + [Fact] + public async Task ActivateTeacherAccount_CreatesAndLinksTeacherLogin() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services + .AddIdentityCore(options => + { + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + }) + .AddRoles() + .AddEntityFrameworkStores(); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var roleManager = scope.ServiceProvider + .GetRequiredService>(); + Assert.True((await roleManager.CreateAsync(new ApplicationRole + { + Name = SystemRoles.Teacher, + Description = "教师" + })).Succeeded); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var teacher = new Teacher + { + TeacherNumber = "T2026999", + Name = "测试教师", + CollegeId = college.Id, + Status = TeacherStatus.Active + }; + db.AddRange(college, teacher); + await db.SaveChangesAsync(); + + var userManager = scope.ServiceProvider + .GetRequiredService>(); + var controller = new PersonnelController( + db, + new TestDataScope(college.Id), + userManager); + + var result = await controller.ActivateTeacherAccount( + teacher.Id, + new TeacherAccountActivationRequest("Teacher@123"), + CancellationToken.None); + + Assert.IsType(result); + var user = await userManager.FindByNameAsync(teacher.TeacherNumber); + Assert.NotNull(user); + Assert.Equal(teacher.Name, user.DisplayName); + Assert.Equal(teacher.CollegeId, user.CollegeId); + Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher)); + await db.Entry(teacher).ReloadAsync(); + Assert.Equal(user.Id, teacher.UserId); + } + + private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + } +} diff --git a/web/src/style.css b/web/src/style.css index 4038bde..2c1f7c9 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1070,6 +1070,8 @@ button { cursor: pointer; } } @media (max-width: 600px) { + .page-intro { align-items: flex-start; flex-direction: column; } + .page-actions { width: 100%; flex-wrap: wrap; } .option-filter-grid.course, .option-filter-grid.course.compact, .option-filter-grid.classes { grid-template-columns: 1fr; } diff --git a/web/src/views/PersonnelView.vue b/web/src/views/PersonnelView.vue index b1b3b90..66149a4 100644 --- a/web/src/views/PersonnelView.vue +++ b/web/src/views/PersonnelView.vue @@ -1,6 +1,6 @@