取消新增/Excel 导入档案时自动创建账号。

取消修改人员档案时同步登录账号。
Excel 人员模板恢复为不含“初始密码”。
登录页新增“学生首次登录?自助激活账号”入口。
公开激活地址:http://127.0.0.1:5255/activate
This commit is contained in:
2026-07-25 07:55:49 +08:00 Unverified
parent 27c3944c28
commit 30c15f89e5
11 changed files with 434 additions and 287 deletions
@@ -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<ApplicationUser> userManager,
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]
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> 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(
@@ -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<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)
{
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);
@@ -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);
}