取消新增/Excel 导入档案时自动创建账号。
取消修改人员档案时同步登录账号。 Excel 人员模板恢复为不含“初始密码”。 登录页新增“学生首次登录?自助激活账号”入口。 公开激活地址:http://127.0.0.1:5255/activate
This commit is contained in:
1 parent
27c3944c28
commit
30c15f89e5
11 files changed
+434
-287
No files matched your search
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
学生和教师档案是登录账号的人员主数据:新增或 Excel 导入档案时,系统以学号/工号自动创建同名登录账号并分配学生/教师角色,后续修改姓名、编号、学院或在籍/在职状态时同步账号。`AspNetUsers` 仅作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌,不需要再手工重复建立学生、教师用户。
|
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,117 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Controllers;
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public sealed class AuthController(
|
public sealed class AuthController(
|
||||||
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService) : ControllerBase
|
ITokenService tokenService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("activation-options")]
|
||||||
|
public async Task<ActionResult> 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<ActionResult> 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]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
||||||
@@ -77,12 +175,36 @@ public sealed class AuthController(
|
|||||||
user.CollegeId,
|
user.CollegeId,
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString());
|
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(
|
public sealed record LoginRequest(
|
||||||
[Required, MaxLength(100)] string UserName,
|
[Required, MaxLength(100)] string UserName,
|
||||||
[Required, MaxLength(100)] string Password);
|
[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 LoginResponse(string Token, CurrentUserResponse User);
|
||||||
|
|
||||||
public sealed record CurrentUserResponse(
|
public sealed record CurrentUserResponse(
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelController(
|
public sealed class PersonnelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
PersonnelAccountService personnelAccountService) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -104,14 +103,7 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Teachers.Add(entity);
|
db.Teachers.Add(entity);
|
||||||
return await CreateWithAccountAsync(
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
||||||
entity.Id,
|
|
||||||
request.InitialPassword,
|
|
||||||
() => personnelAccountService.EnsureTeacherAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("teachers/{id:guid}")]
|
[HttpPut("teachers/{id:guid}")]
|
||||||
@@ -138,18 +130,6 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
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);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +239,7 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Students.Add(entity);
|
db.Students.Add(entity);
|
||||||
return await CreateWithAccountAsync(
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
||||||
entity.Id,
|
|
||||||
request.InitialPassword,
|
|
||||||
() => personnelAccountService.EnsureStudentAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("students/{id:guid}")]
|
[HttpPut("students/{id:guid}")]
|
||||||
@@ -303,18 +276,6 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
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);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,39 +372,6 @@ public sealed class PersonnelController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ActionResult> CreateWithAccountAsync(
|
|
||||||
Guid id,
|
|
||||||
string? initialPassword,
|
|
||||||
Func<Task<PersonnelAccountResult>> 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<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -465,14 +393,6 @@ public sealed class PersonnelController(
|
|||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
|
|
||||||
private ActionResult AccountProblem(string detail) =>
|
|
||||||
Conflict(new ProblemDetails
|
|
||||||
{
|
|
||||||
Title = "登录账号创建失败",
|
|
||||||
Detail = detail,
|
|
||||||
Status = StatusCodes.Status409Conflict
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
@@ -502,8 +422,7 @@ public sealed record TeacherRequest(
|
|||||||
bool IsExternal,
|
bool IsExternal,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes,
|
[MaxLength(500)] string? Notes);
|
||||||
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
|
||||||
|
|
||||||
public sealed record StudentRequest(
|
public sealed record StudentRequest(
|
||||||
[Required, MaxLength(30)] string StudentNumber,
|
[Required, MaxLength(30)] string StudentNumber,
|
||||||
@@ -516,5 +435,4 @@ public sealed record StudentRequest(
|
|||||||
DateOnly? DateOfBirth,
|
DateOnly? DateOfBirth,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes,
|
[MaxLength(500)] string? Notes);
|
||||||
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
|
||||||
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user