From 30c15f89e55c080281bd06a76ef777d3258a25d2 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 07:55:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=96=B0=E5=A2=9E/Excel=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A1=A3=E6=A1=88=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E8=B4=A6=E5=8F=B7=E3=80=82=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E4=BF=AE=E6=94=B9=E4=BA=BA=E5=91=98=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E6=97=B6=E5=90=8C=E6=AD=A5=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E3=80=82=20Excel=20=E4=BA=BA=E5=91=98=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=BA=E4=B8=8D=E5=90=AB=E2=80=9C=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=AF=86=E7=A0=81=E2=80=9D=E3=80=82=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E6=96=B0=E5=A2=9E=E2=80=9C=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E7=99=BB=E5=BD=95=EF=BC=9F=E8=87=AA=E5=8A=A9?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E8=B4=A6=E5=8F=B7=E2=80=9D=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=82=20=E5=85=AC=E5=BC=80=E6=BF=80=E6=B4=BB=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=EF=BC=9Ahttp://127.0.0.1:5255/activate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/Jiaowu.Api/Controllers/AuthController.cs | 122 ++++++++ .../Controllers/PersonnelController.cs | 92 +----- .../Controllers/PersonnelExcelController.cs | 24 +- .../Auth/PersonnelAccountService.cs | 144 ---------- src/Jiaowu.Api/Program.cs | 18 +- web/src/router/index.ts | 6 + web/src/style.css | 1 + web/src/views/AccountActivationView.vue | 268 ++++++++++++++++++ web/src/views/LoginView.vue | 6 +- web/src/views/PersonnelView.vue | 38 +-- 11 files changed, 434 insertions(+), 287 deletions(-) delete mode 100644 src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs create mode 100644 web/src/views/AccountActivationView.vue diff --git a/README.md b/README.md index 764cae9..9af7ef5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 -学生和教师档案是登录账号的人员主数据:新增或 Excel 导入档案时,系统以学号/工号自动创建同名登录账号并分配学生/教师角色,后续修改姓名、编号、学院或在籍/在职状态时同步账号。`AspNetUsers` 仅作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌,不需要再手工重复建立学生、教师用户。 +人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。 ## 本地开发:热更新模式 diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index 97dc012..b85975b 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -1,19 +1,117 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; +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.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Route("api/auth")] public sealed class AuthController( + AppDbContext db, UserManager userManager, ITokenService tokenService) : ControllerBase { + [AllowAnonymous] + [HttpGet("activation-options")] + public async Task GetActivationOptions(CancellationToken cancellationToken) + { + var colleges = await db.Colleges.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name }) + .ToListAsync(cancellationToken); + var majors = await db.Majors.AsNoTracking() + .Where(x => x.IsEnabled && x.College!.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) + .ToListAsync(cancellationToken); + var classes = await db.AdministrativeClasses.AsNoTracking() + .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled) + .OrderByDescending(x => x.Grade) + .ThenBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId }) + .ToListAsync(cancellationToken); + return Ok(new + { + Colleges = colleges, + Majors = majors, + Classes = classes, + Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x) + }); + } + + [AllowAnonymous] + [EnableRateLimiting("public-auth")] + [HttpPost("activate-student")] + public async Task ActivateStudent( + StudentActivationRequest request, + CancellationToken cancellationToken) + { + var name = request.Name.Trim(); + var studentNumber = request.StudentNumber.Trim(); + var student = await db.Students + .Include(x => x.AdministrativeClass) + .ThenInclude(x => x!.Major) + .FirstOrDefaultAsync(x => + x.Status == StudentStatus.Active && + x.Name == name && + x.StudentNumber == studentNumber && + x.EnrollmentYear == request.Grade && + x.AdministrativeClassId == request.AdministrativeClassId && + x.AdministrativeClass!.Grade == request.Grade && + x.AdministrativeClass.MajorId == request.MajorId && + x.AdministrativeClass.Major!.CollegeId == request.CollegeId, + cancellationToken); + if (student is null) + return ActivationProblem( + "填写的信息与在籍学生档案不完全一致,请核对后重试。", + StatusCodes.Status400BadRequest); + if (student.UserId.HasValue) + return ActivationProblem( + "该学号已经激活,请直接登录;如忘记密码请联系管理员重置。", + StatusCodes.Status409Conflict); + if (await userManager.FindByNameAsync(studentNumber) is not null) + return ActivationProblem( + "该学号已有登录账号但未正确关联,请联系管理员处理。", + StatusCodes.Status409Conflict); + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + var user = new ApplicationUser + { + UserName = studentNumber, + DisplayName = student.Name, + StaffNumber = studentNumber, + CollegeId = request.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.Student); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } + + student.UserId = user.Id; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { UserName = studentNumber }); + } + [AllowAnonymous] [HttpPost("login")] public async Task> Login(LoginRequest request) @@ -77,12 +175,36 @@ public sealed class AuthController( user.CollegeId, EffectiveDataScopeResolver.Resolve(roles).ToString()); } + + private ActionResult IdentityValidationProblem(IdentityResult result) + { + foreach (var error in result.Errors) + ModelState.AddModelError(error.Code, error.Description); + return ValidationProblem(ModelState); + } + + private ActionResult ActivationProblem(string detail, int status) => + StatusCode(status, new ProblemDetails + { + Title = "账号激活失败", + Detail = detail, + Status = status + }); } public sealed record LoginRequest( [Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string Password); +public sealed record StudentActivationRequest( + [Required, MaxLength(50)] string Name, + [Required, MaxLength(30)] string StudentNumber, + Guid CollegeId, + Guid MajorId, + [Range(2000, 2200)] int Grade, + Guid AdministrativeClassId, + [Required, MinLength(8), MaxLength(100)] string Password); + public sealed record LoginResponse(string Token, CurrentUserResponse User); public sealed record CurrentUserResponse( diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index f2c5227..e47db5a 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -15,8 +15,7 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope, - PersonnelAccountService personnelAccountService) : ControllerBase + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -104,14 +103,7 @@ public sealed class PersonnelController( Notes = Normalize(request.Notes) }; db.Teachers.Add(entity); - return await CreateWithAccountAsync( - entity.Id, - request.InitialPassword, - () => personnelAccountService.EnsureTeacherAccountAsync( - entity, - request.InitialPassword, - cancellationToken), - cancellationToken); + return await SaveCreatedAsync(entity.Id, cancellationToken); } [HttpPut("teachers/{id:guid}")] @@ -138,18 +130,6 @@ public sealed class PersonnelController( entity.Phone = Normalize(request.Phone); entity.Email = Normalize(request.Email); entity.Notes = Normalize(request.Notes); - if (!entity.UserId.HasValue && - (string.IsNullOrWhiteSpace(request.InitialPassword) || - request.InitialPassword.Length < 8)) - return ValidationProblem("该教师档案尚未开通账号,请填写至少 8 位初始密码。"); - if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword)) - { - var account = await personnelAccountService.EnsureTeacherAccountAsync( - entity, - request.InitialPassword, - cancellationToken); - if (!account.Success) return AccountProblem(account.Error!); - } return await SaveNoContentAsync(cancellationToken); } @@ -259,14 +239,7 @@ public sealed class PersonnelController( Notes = Normalize(request.Notes) }; db.Students.Add(entity); - return await CreateWithAccountAsync( - entity.Id, - request.InitialPassword, - () => personnelAccountService.EnsureStudentAccountAsync( - entity, - request.InitialPassword, - cancellationToken), - cancellationToken); + return await SaveCreatedAsync(entity.Id, cancellationToken); } [HttpPut("students/{id:guid}")] @@ -303,18 +276,6 @@ public sealed class PersonnelController( entity.Phone = Normalize(request.Phone); entity.Email = Normalize(request.Email); entity.Notes = Normalize(request.Notes); - if (!entity.UserId.HasValue && - (string.IsNullOrWhiteSpace(request.InitialPassword) || - request.InitialPassword.Length < 8)) - return ValidationProblem("该学生档案尚未开通账号,请填写至少 8 位初始密码。"); - if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword)) - { - var account = await personnelAccountService.EnsureStudentAccountAsync( - entity, - request.InitialPassword, - cancellationToken); - if (!account.Success) return AccountProblem(account.Error!); - } return await SaveNoContentAsync(cancellationToken); } @@ -411,39 +372,6 @@ public sealed class PersonnelController( } } - private async Task CreateWithAccountAsync( - Guid id, - string? initialPassword, - Func> createAccount, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(initialPassword) || initialPassword.Length < 8) - return ValidationProblem("新增人员时必须填写至少 8 位初始密码。"); - - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - try - { - var account = await createAccount(); - if (!account.Success) - { - await transaction.RollbackAsync(cancellationToken); - return AccountProblem(account.Error!); - } - await transaction.CommitAsync(cancellationToken); - return Created(string.Empty, new - { - id, - account.UserId, - account.UserName - }); - } - catch (DbUpdateException) - { - await transaction.RollbackAsync(cancellationToken); - return ConflictProblem("编号已存在,或关联数据无效。"); - } - } - private async Task SaveNoContentAsync(CancellationToken cancellationToken) { try @@ -465,14 +393,6 @@ public sealed class PersonnelController( Status = StatusCodes.Status409Conflict }); - private ActionResult AccountProblem(string detail) => - Conflict(new ProblemDetails - { - Title = "登录账号创建失败", - Detail = detail, - Status = StatusCodes.Status409Conflict - }); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -502,8 +422,7 @@ public sealed record TeacherRequest( bool IsExternal, [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, - [MaxLength(500)] string? Notes, - [MinLength(8), MaxLength(100)] string? InitialPassword = null); + [MaxLength(500)] string? Notes); public sealed record StudentRequest( [Required, MaxLength(30)] string StudentNumber, @@ -516,5 +435,4 @@ public sealed record StudentRequest( DateOnly? DateOfBirth, [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, - [MaxLength(500)] string? Notes, - [MinLength(8), MaxLength(100)] string? InitialPassword = null); + [MaxLength(500)] string? Notes); diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index 30777be..69f8aff 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -14,8 +14,7 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelExcelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope, - PersonnelAccountService personnelAccountService) : ControllerBase + ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -33,13 +32,13 @@ public sealed class PersonnelExcelController( private static readonly string[] TeacherHeaders = [ "工号", "姓名", "性别", "学院编码", "职称", "任职状态", - "入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码" + "入职日期", "教师类别", "联系电话", "电子邮箱", "备注" ]; private static readonly string[] StudentHeaders = [ "学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期", - "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码" + "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注" ]; [HttpGet("{kind}/template")] @@ -57,7 +56,6 @@ public sealed class PersonnelExcelController( ? "工号是唯一标识;学院编码必须已存在。" : "学号是唯一标识;行政班编码必须已存在。", "编号已存在时更新档案,不存在时新增档案。", - "新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。", "日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。", "整批数据会先校验,任一行有误时均不会写入。" ]); @@ -83,7 +81,7 @@ public sealed class PersonnelExcelController( .Select(x => Row( x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code, x.Title, TeacherStatusName(x.Status), x.HireDate, - x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes, null)) + x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes)) .ToList(); } else @@ -96,7 +94,7 @@ public sealed class PersonnelExcelController( .Select(x => Row( x.StudentNumber, x.Name, GenderName(x.Gender), x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate, - StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes, null)) + StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes)) .ToList(); } @@ -214,12 +212,6 @@ public sealed class PersonnelExcelController( entity.Phone = Optional(row, "联系电话"); entity.Email = Optional(row, "电子邮箱"); entity.Notes = Optional(row, "备注"); - var account = await personnelAccountService.EnsureTeacherAccountAsync( - entity, - Optional(row, "初始密码"), - cancellationToken); - if (!account.Success) - errors.Add($"第 {row.RowNumber} 行:{account.Error}"); } return new(created, updated, rows.Count); } @@ -292,12 +284,6 @@ public sealed class PersonnelExcelController( entity.Phone = Optional(row, "联系电话"); entity.Email = Optional(row, "电子邮箱"); entity.Notes = Optional(row, "备注"); - var account = await personnelAccountService.EnsureStudentAccountAsync( - entity, - Optional(row, "初始密码"), - cancellationToken); - if (!account.Success) - errors.Add($"第 {row.RowNumber} 行:{account.Error}"); } return new(created, updated, rows.Count); } diff --git a/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs b/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs deleted file mode 100644 index 569b61f..0000000 --- a/src/Jiaowu.Api/Infrastructure/Auth/PersonnelAccountService.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Jiaowu.Api.Domain.Academic; -using Jiaowu.Api.Domain.Identity; -using Jiaowu.Api.Infrastructure.Persistence; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; - -namespace Jiaowu.Api.Infrastructure.Auth; - -public sealed class PersonnelAccountService( - AppDbContext db, - UserManager userManager) -{ - public Task EnsureTeacherAccountAsync( - Teacher teacher, - string? initialPassword, - CancellationToken cancellationToken) => - EnsureAccountAsync( - teacher.Id, - teacher.UserId, - teacher.TeacherNumber, - teacher.Name, - teacher.CollegeId, - teacher.Status == TeacherStatus.Active, - SystemRoles.Teacher, - initialPassword, - userId => teacher.UserId = userId, - cancellationToken); - - public async Task EnsureStudentAccountAsync( - Student student, - string? initialPassword, - CancellationToken cancellationToken) - { - var collegeId = await db.AdministrativeClasses.AsNoTracking() - .Where(x => x.Id == student.AdministrativeClassId) - .Select(x => (Guid?)x.Major!.CollegeId) - .FirstOrDefaultAsync(cancellationToken); - if (!collegeId.HasValue) - return PersonnelAccountResult.Failed("学生所在行政班或学院不存在。"); - - return await EnsureAccountAsync( - student.Id, - student.UserId, - student.StudentNumber, - student.Name, - collegeId.Value, - student.Status == StudentStatus.Active, - SystemRoles.Student, - initialPassword, - userId => student.UserId = userId, - cancellationToken); - } - - private async Task EnsureAccountAsync( - Guid profileId, - Guid? linkedUserId, - string number, - string displayName, - Guid collegeId, - bool isEnabled, - string role, - string? initialPassword, - Action linkProfile, - CancellationToken cancellationToken) - { - var normalizedNumber = number.Trim(); - ApplicationUser? user = null; - if (linkedUserId.HasValue) - user = await userManager.FindByIdAsync(linkedUserId.Value.ToString()); - user ??= await userManager.FindByNameAsync(normalizedNumber); - - if (user is null) - { - if (string.IsNullOrWhiteSpace(initialPassword)) - return PersonnelAccountResult.Failed("请填写至少 8 位初始密码,以便同时创建登录账号。"); - - user = new ApplicationUser - { - UserName = normalizedNumber, - DisplayName = displayName.Trim(), - StaffNumber = normalizedNumber, - CollegeId = collegeId, - IsEnabled = isEnabled, - LockoutEnabled = true - }; - var createResult = await userManager.CreateAsync(user, initialPassword); - if (!createResult.Succeeded) return Failed(createResult); - } - else - { - var occupied = role == SystemRoles.Teacher - ? await db.Teachers.AsNoTracking().AnyAsync( - x => - x.Id != profileId && - x.UserId == user.Id && - x.TeacherNumber != normalizedNumber, - cancellationToken) - : await db.Students.AsNoTracking().AnyAsync( - x => - x.Id != profileId && - x.UserId == user.Id && - x.StudentNumber != normalizedNumber, - cancellationToken); - if (occupied) - return PersonnelAccountResult.Failed($"登录账号“{normalizedNumber}”已关联其他人员档案。"); - - user.UserName = normalizedNumber; - user.DisplayName = displayName.Trim(); - user.StaffNumber = normalizedNumber; - user.CollegeId = collegeId; - user.IsEnabled = isEnabled; - var updateResult = await userManager.UpdateAsync(user); - if (!updateResult.Succeeded) return Failed(updateResult); - } - - if (!await userManager.IsInRoleAsync(user, role)) - { - var roleResult = await userManager.AddToRoleAsync(user, role); - if (!roleResult.Succeeded) return Failed(roleResult); - } - - linkProfile(user.Id); - await db.SaveChangesAsync(cancellationToken); - return PersonnelAccountResult.Succeeded(user.Id, normalizedNumber); - } - - private static PersonnelAccountResult Failed(IdentityResult result) => - PersonnelAccountResult.Failed(string.Join( - ";", - result.Errors.Select(x => x.Description))); -} - -public sealed record PersonnelAccountResult( - bool Success, - Guid? UserId, - string? UserName, - string? Error) -{ - public static PersonnelAccountResult Succeeded(Guid userId, string userName) => - new(true, userId, userName, null); - - public static PersonnelAccountResult Failed(string error) => - new(false, null, null, error); -} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 9f34f57..4eeddd9 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -6,10 +6,12 @@ using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); @@ -85,7 +87,6 @@ builder.Services.Configure( builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -112,6 +113,20 @@ builder.Services }; }); builder.Services.AddAuthorization(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("public-auth", context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(5), + QueueLimit = 0, + AutoReplenishment = true + })); +}); builder.Services.AddCors(options => { @@ -212,6 +227,7 @@ app.UseStaticFiles(new StaticFileOptions } }); app.UseCors("Web"); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseMiddleware(); diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 7e9f0a6..8f93f94 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -17,6 +17,12 @@ const router = createRouter({ component: () => import('../views/TimetableView.vue'), meta: { public: true }, }, + { + path: '/activate', + name: 'activate-account', + component: () => import('../views/AccountActivationView.vue'), + meta: { public: true }, + }, { path: '/', component: AdminLayout, diff --git a/web/src/style.css b/web/src/style.css index 2bcf364..bc12601 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -939,6 +939,7 @@ button { cursor: pointer; } .login-form label { display: block; margin-bottom: 20px; } .login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; } .public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } +.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } .login-submit { width: 100%; margin-top: 6px; height: 46px; } .dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; } diff --git a/web/src/views/AccountActivationView.vue b/web/src/views/AccountActivationView.vue new file mode 100644 index 0000000..d0291a1 --- /dev/null +++ b/web/src/views/AccountActivationView.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 58ce995..763b856 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -8,7 +8,10 @@ const route = useRoute() const router = useRouter() const auth = useAuthStore() const loading = ref(false) -const form = reactive({ userName: 'admin', password: 'Admin@123456' }) +const form = reactive({ + userName: String(route.query.userName ?? 'admin'), + password: route.query.activated ? '' : 'Admin@123456', +}) async function submit() { loading.value = true @@ -80,6 +83,7 @@ async function submit() { 进入工作台 无需登录,查询班级课表 → +
本地开发账号 admin / Admin@123456 diff --git a/web/src/views/PersonnelView.vue b/web/src/views/PersonnelView.vue index 3698315..b1b3b90 100644 --- a/web/src/views/PersonnelView.vue +++ b/web/src/views/PersonnelView.vue @@ -113,7 +113,6 @@ function resetForm(row?: any) { phone: '', email: '', notes: '', - initialPassword: '', }, row ?? {}) } else { const currentYear = new Date().getFullYear() @@ -129,7 +128,6 @@ function resetForm(row?: any) { phone: '', email: '', notes: '', - initialPassword: '', }, row ?? {}) } } @@ -268,22 +266,11 @@ async function save() { ElMessage.warning(`请填写${numberLabel.value}和姓名。`) return } - if ((!editingId.value || !form.userId) && String(form.initialPassword ?? '').length < 8) { - ElMessage.warning('请设置至少 8 位初始密码,系统会同时创建同号登录账号。') - return - } try { const path = `/personnel/${active.value}` - const payload = { ...form } - if (editingId.value && form.userId) { - delete payload.initialPassword - } - if (editingId.value) { - await http.put(`${path}/${editingId.value}`, payload) - } else { - await http.post(path, payload) - } - ElMessage.success(editingId.value ? '档案与登录账号已同步' : '档案和同号登录账号已建立') + if (editingId.value) await http.put(`${path}/${editingId.value}`, form) + else await http.post(path, form) + ElMessage.success(editingId.value ? '档案已更新' : '档案已建立') dialogVisible.value = false await load() } catch (error) { @@ -423,7 +410,7 @@ watch(active, async () => { @@ -521,23 +508,6 @@ watch(active, async () => {
- - - -