This commit is contained in:
2026-07-24 22:18:32 +08:00 Unverified
parent d67a07f23e
commit 27c3944c28
12 changed files with 781 additions and 14 deletions
@@ -15,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/personnel")]
public sealed class PersonnelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
PersonnelAccountService personnelAccountService) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
@@ -103,7 +104,14 @@ public sealed class PersonnelController(
Notes = Normalize(request.Notes)
};
db.Teachers.Add(entity);
return await SaveCreatedAsync(entity.Id, cancellationToken);
return await CreateWithAccountAsync(
entity.Id,
request.InitialPassword,
() => personnelAccountService.EnsureTeacherAccountAsync(
entity,
request.InitialPassword,
cancellationToken),
cancellationToken);
}
[HttpPut("teachers/{id:guid}")]
@@ -130,6 +138,18 @@ 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);
}
@@ -239,7 +259,14 @@ public sealed class PersonnelController(
Notes = Normalize(request.Notes)
};
db.Students.Add(entity);
return await SaveCreatedAsync(entity.Id, cancellationToken);
return await CreateWithAccountAsync(
entity.Id,
request.InitialPassword,
() => personnelAccountService.EnsureStudentAccountAsync(
entity,
request.InitialPassword,
cancellationToken),
cancellationToken);
}
[HttpPut("students/{id:guid}")]
@@ -276,6 +303,18 @@ 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);
}
@@ -372,6 +411,39 @@ 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
@@ -393,6 +465,14 @@ 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();
@@ -422,7 +502,8 @@ public sealed record TeacherRequest(
bool IsExternal,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
public sealed record StudentRequest(
[Required, MaxLength(30)] string StudentNumber,
@@ -435,4 +516,5 @@ public sealed record StudentRequest(
DateOnly? DateOfBirth,
[MaxLength(30)] string? Phone,
[EmailAddress, MaxLength(100)] string? Email,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
@@ -14,7 +14,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/personnel")]
public sealed class PersonnelExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
PersonnelAccountService personnelAccountService) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
@@ -32,13 +33,13 @@ public sealed class PersonnelExcelController(
private static readonly string[] TeacherHeaders =
[
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注"
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码"
];
private static readonly string[] StudentHeaders =
[
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码"
];
[HttpGet("{kind}/template")]
@@ -56,6 +57,7 @@ public sealed class PersonnelExcelController(
? "工号是唯一标识;学院编码必须已存在。"
: "学号是唯一标识;行政班编码必须已存在。",
"编号已存在时更新档案,不存在时新增档案。",
"新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。",
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
"整批数据会先校验,任一行有误时均不会写入。"
]);
@@ -81,7 +83,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))
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes, null))
.ToList();
}
else
@@ -94,7 +96,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))
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes, null))
.ToList();
}
@@ -212,6 +214,12 @@ 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);
}
@@ -284,6 +292,12 @@ 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);
}
@@ -0,0 +1,231 @@
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.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/timetables")]
public sealed class TimetablesController(AppDbContext db) : ControllerBase
{
[HttpGet("options")]
[AllowAnonymous]
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
{
var terms = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderByDescending(x => x.StartDate)
.Select(x => new
{
x.Id,
x.Name,
x.AcademicYear,
x.Season,
x.StartDate,
x.EndDate,
x.IsCurrent,
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == SchedulePlanStatus.Published)
})
.ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
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,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name,
HasPublishedTimetable = defaultTermId.HasValue &&
db.ScheduleEntries.Any(entry =>
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id))
})
.ToListAsync(cancellationToken);
return Ok(new { Terms = terms, Classes = classes });
}
[HttpGet("classes/{classId:guid}")]
[AllowAnonymous]
public Task<ActionResult> GetClassTimetable(
Guid classId,
Guid? academicTermId,
CancellationToken cancellationToken) =>
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyTimetable(
Guid? academicTermId,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new
{
x.Id,
x.AdministrativeClassId,
x.StudentNumber,
x.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null)
return Conflict(new ProblemDetails
{
Title = "学生档案未关联",
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
return await BuildTimetableAsync(
student.AdministrativeClassId,
academicTermId,
student.Id,
cancellationToken,
new { student.StudentNumber, student.Name });
}
private async Task<ActionResult> BuildTimetableAsync(
Guid classId,
Guid? academicTermId,
Guid? studentId,
CancellationToken cancellationToken,
object? student = null)
{
var administrativeClass = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.Id == classId && x.IsEnabled)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Grade,
MajorName = x.Major!.Name,
CollegeName = x.Major.College!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (administrativeClass is null) return NotFound();
var termQuery = db.AcademicTerms.AsNoTracking().Where(x => x.IsEnabled);
if (academicTermId.HasValue)
termQuery = termQuery.Where(x => x.Id == academicTermId);
else
termQuery = termQuery.OrderByDescending(x => x.IsCurrent)
.ThenByDescending(x => x.StartDate);
var term = await termQuery
.Select(x => new
{
x.Id,
x.Name,
x.AcademicYear,
x.Season,
x.StartDate,
x.EndDate,
x.IsCurrent
})
.FirstOrDefaultAsync(cancellationToken);
if (term is null) return NotFound();
var plan = await db.SchedulePlans.AsNoTracking()
.Where(x =>
x.AcademicTermId == term.Id &&
x.Status == SchedulePlanStatus.Published)
.Select(x => new { x.Id, x.Name, x.Version, x.PublishedAt })
.FirstOrDefaultAsync(cancellationToken);
var slots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt })
.ToListAsync(cancellationToken);
if (plan is null)
return Ok(new
{
Term = term,
Class = administrativeClass,
Student = student,
Plan = (object?)null,
Slots = slots,
Entries = Array.Empty<object>()
});
var entries = db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == classId));
if (studentId.HasValue)
{
var selectedTaskIds = db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == term.Id)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId);
entries = db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
(x.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == classId) ||
selectedTaskIds.Contains(x.TeachingTaskId)));
}
var result = await entries
.OrderBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
TaskNumber = x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
ClassroomName = x.Classroom == null ? "不占用教室" : x.Classroom.Name,
BuildingName = x.Classroom == null ? null : x.Classroom.Building!.Name,
CampusName = x.Classroom == null
? null
: x.Classroom.Building!.Campus!.Name,
x.DayOfWeek,
x.StartPeriod,
x.PeriodCount,
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.Notes
})
.ToListAsync(cancellationToken);
return Ok(new
{
Term = term,
Class = administrativeClass,
Student = student,
Plan = plan,
Slots = slots,
Entries = result
});
}
}