课程库 Excel 导入导出已完成:

支持下载标准导入模板。
导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。
以课程编码为唯一键:存在则更新,不存在则新增。
校验学院编码、课程分类、课程性质、学分学时及考核方式。
整批原子导入,任一行错误则全部不写入,并提示具体行号。
学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。
界面入口沿用现有课程库工具栏样式。
This commit is contained in:
2026-07-24 18:56:25 +08:00 Unverified
parent 5a7a8d530b
commit d7cf3f9e76
24 changed files with 3710 additions and 32 deletions
@@ -120,6 +120,44 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
})
.ToListAsync(cancellationToken));
[HttpGet("course-categories")]
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
CancellationToken cancellationToken) =>
await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
[HttpPost("course-categories")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<CourseCategory>> CreateCourseCategory(
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = new CourseCategory
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
SortOrder = request.SortOrder,
IsEnabled = request.IsEnabled
};
return await CreateAsync(entity, nameof(GetCourseCategories), cancellationToken);
}
[HttpPut("course-categories/{id:guid}")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<CourseCategory>> UpdateCourseCategory(
Guid id,
CatalogRequest request,
CancellationToken cancellationToken)
{
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
ApplyCatalog(entity, request);
await db.SaveChangesAsync(cancellationToken);
return entity;
}
[HttpPost("majors")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<Major>> CreateMajor(
@@ -403,6 +441,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
"course-categories" => await db.CourseCategories.FindAsync([id], cancellationToken),
_ => null
};
@@ -26,7 +26,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"]
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
["course-categories"] = ["编码", "名称", "排序", "状态"]
};
[HttpGet("{kind}/template")]
@@ -90,6 +91,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
"course-categories" => await ImportCourseCategoriesAsync(
rows, errors, cancellationToken),
_ => throw new InvalidOperationException()
};
@@ -147,6 +150,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
"course-categories" => (await db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.SortOrder, Status(x.IsEnabled))).ToList(),
_ => []
};
@@ -471,6 +478,36 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
return new(created, updated, rows.Count);
}
private async Task<ExcelImportResult> ImportCourseCategoriesAsync(
IReadOnlyList<ExcelRow> rows,
List<string> errors,
CancellationToken cancellationToken)
{
var existing = await db.CourseCategories.ToDictionaryAsync(
x => x.Code,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var created = 0;
var updated = 0;
foreach (var row in rows)
{
var code = Required(row, "编码", errors);
var name = Required(row, "名称", errors);
if (code is null || name is null) continue;
var entity = existing.GetValueOrDefault(code);
if (entity is null)
{
entity = new CourseCategory { Code = code, Name = name };
db.CourseCategories.Add(entity);
existing[code] = entity;
created++;
}
else updated++;
ApplyCatalog(entity, row, name, errors);
}
return new(created, updated, rows.Count);
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50))
@@ -582,6 +619,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
"terms" => "学期",
"buildings" => "教学楼",
"classrooms" => "教室",
"course-categories" => "课程分类",
_ => "基础数据"
};
}
@@ -28,6 +28,7 @@ public sealed class CoursesController(
int pageSize = 20,
string? keyword = null,
Guid? collegeId = null,
Guid? categoryId = null,
CourseNature? nature = null,
bool? isEnabled = null,
CancellationToken cancellationToken = default)
@@ -42,6 +43,8 @@ public sealed class CoursesController(
var source = ScopedCourses().AsNoTracking();
if (collegeId.HasValue)
source = source.Where(x => x.CollegeId == collegeId.Value);
if (categoryId.HasValue)
source = source.Where(x => x.CourseCategoryId == categoryId.Value);
if (nature.HasValue)
source = source.Where(x => x.Nature == nature.Value);
if (isEnabled.HasValue)
@@ -69,6 +72,9 @@ public sealed class CoursesController(
x.EnglishName,
x.CollegeId,
CollegeName = x.College!.Name,
x.CourseCategoryId,
CategoryCode = x.CourseCategory != null ? x.CourseCategory.Code : null,
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits,
x.TotalHours,
x.LectureHours,
@@ -106,6 +112,7 @@ public sealed class CoursesController(
Name = request.Name.Trim(),
EnglishName = Normalize(request.EnglishName),
CollegeId = request.CollegeId,
CourseCategoryId = request.CourseCategoryId,
Credits = request.Credits,
TotalHours = request.TotalHours,
LectureHours = request.LectureHours,
@@ -137,6 +144,7 @@ public sealed class CoursesController(
entity.Name = request.Name.Trim();
entity.EnglishName = Normalize(request.EnglishName);
entity.CollegeId = request.CollegeId;
entity.CourseCategoryId = request.CourseCategoryId;
entity.Credits = request.Credits;
entity.TotalHours = request.TotalHours;
entity.LectureHours = request.LectureHours;
@@ -167,6 +175,10 @@ public sealed class CoursesController(
if (!CanManage(request.CollegeId, request.Nature)) return Forbid();
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
return ValidationProblem("所选学院不存在。");
if (!await db.CourseCategories.AnyAsync(
x => x.Id == request.CourseCategoryId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选课程分类不存在或已停用。");
if (request.LectureHours + request.PracticeHours > request.TotalHours)
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
return null;
@@ -242,6 +254,7 @@ public sealed record CourseRequest(
[Required, MaxLength(100)] string Name,
[MaxLength(150)] string? EnglishName,
Guid CollegeId,
Guid CourseCategoryId,
[Range(typeof(decimal), "0.1", "99")] decimal Credits,
[Range(1, 1000)] int TotalHours,
[Range(0, 1000)] int LectureHours,
@@ -0,0 +1,482 @@
using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/courses")]
public sealed class CoursesExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private static readonly string[] Headers =
[
"课程编码", "课程名称", "英文名称", "开课学院编码", "课程分类编码",
"课程性质", "学分", "总学时", "讲授学时", "实践学时",
"考核方式", "课程简介", "启用状态", "排序"
];
[HttpGet("template")]
[Authorize(Roles = WriteRoles)]
public IActionResult DownloadTemplate()
{
var bytes = ExcelWorkbookHelper.Create(
"课程库导入",
Headers,
[],
[
"请勿修改第一行列名;每行填写一门课程,课程编码是全校唯一标识。",
"开课学院编码、课程分类编码必须已在系统中存在;课程分类还必须处于启用状态。",
"课程性质请填写:通识必修、专业必修、专业选修、通识选修或实践课程。",
"考核方式请填写考试或考查;启用状态请填写是或否。",
"课程编码已存在时更新课程,不存在时新增课程。",
"学院管理员只能维护本学院的专业必修、专业选修和实践课程。",
"讲授学时与实践学时之和不能超过总学时;整批任一行有误时均不会写入。"
]);
return File(
bytes,
ExcelWorkbookHelper.ContentType,
"课程库导入模板.xlsx");
}
[HttpGet("export")]
public async Task<IActionResult> Export(
string? keyword = null,
Guid? collegeId = null,
Guid? categoryId = null,
CourseNature? nature = null,
bool? isEnabled = null,
CancellationToken cancellationToken = default)
{
var source = ApplyFilters(
ScopedCourses().AsNoTracking(),
keyword,
collegeId,
categoryId,
nature,
isEnabled);
var courses = await source
.Include(x => x.College)
.Include(x => x.CourseCategory)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.ToListAsync(cancellationToken);
var rows = courses
.Select(x => Row(
x.Code,
x.Name,
x.EnglishName,
x.College!.Code,
x.CourseCategory?.Code,
NatureName(x.Nature),
x.Credits,
x.TotalHours,
x.LectureHours,
x.PracticeHours,
AssessmentName(x.AssessmentMethod),
x.Description,
x.IsEnabled ? "是" : "否",
x.SortOrder))
.ToList();
var bytes = ExcelWorkbookHelper.Create("课程库", Headers, rows);
return File(
bytes,
ExcelWorkbookHelper.ContentType,
$"课程库-{DateTime.Now:yyyyMMdd-HHmm}.xlsx");
}
[HttpPost("import")]
[Authorize(Roles = WriteRoles)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult<ExcelImportResult>> Import(
IFormFile file,
CancellationToken cancellationToken)
{
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(
file,
Headers,
cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的课程数据。");
var errors = new List<string>();
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
try
{
var result = await ImportRowsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict
});
}
}
private async Task<ExcelImportResult> ImportRowsAsync(
IReadOnlyList<ExcelRow> rows,
List<string> errors,
CancellationToken cancellationToken)
{
var existing = await db.Courses.ToDictionaryAsync(
x => x.Code,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
x => x.Code,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var categories = await db.CourseCategories.AsNoTracking().ToDictionaryAsync(
x => x.Code,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var importedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var created = 0;
var updated = 0;
foreach (var row in rows)
{
var code = Required(row, "课程编码", 40, errors);
var name = Required(row, "课程名称", 100, errors);
var englishName = Optional(row, "英文名称", 150, errors);
var collegeCode = Required(row, "开课学院编码", 40, errors);
var categoryCode = Required(row, "课程分类编码", 40, errors);
var nature = ParseNature(row, errors);
var credits = ParseDecimal(row, "学分", 0.1m, 99m, errors);
var totalHours = ParseInt(row, "总学时", 1, 1000, errors);
var lectureHours = ParseInt(row, "讲授学时", 0, 1000, errors);
var practiceHours = ParseInt(row, "实践学时", 0, 1000, errors);
var assessment = ParseAssessment(row, errors);
var description = Optional(row, "课程简介", 1000, errors);
var enabled = ParseEnabled(row, errors);
var sortOrder = ParseInt(row, "排序", 0, int.MaxValue, errors);
if (code is null || name is null || collegeCode is null ||
categoryCode is null || nature is null || credits is null ||
totalHours is null || lectureHours is null ||
practiceHours is null || assessment is null ||
enabled is null || sortOrder is null)
continue;
if (!importedCodes.Add(code))
{
errors.Add($"第 {row.RowNumber} 行:课程编码“{code}”在文件中重复。");
continue;
}
if (!colleges.TryGetValue(collegeCode, out var college))
{
errors.Add($"第 {row.RowNumber} 行:开课学院编码“{collegeCode}”不存在。");
continue;
}
if (!categories.TryGetValue(categoryCode, out var category))
{
errors.Add($"第 {row.RowNumber} 行:课程分类编码“{categoryCode}”不存在。");
continue;
}
if (!category.IsEnabled)
{
errors.Add($"第 {row.RowNumber} 行:课程分类“{category.Name}”已停用。");
continue;
}
if (lectureHours.Value + practiceHours.Value > totalHours.Value)
{
errors.Add(
$"第 {row.RowNumber} 行:讲授学时与实践学时之和不能超过总学时。");
continue;
}
if (!CanManage(college.Id, nature.Value))
{
errors.Add(
$"第 {row.RowNumber} 行:无权维护学院“{college.Name}”的“{NatureName(nature.Value)}”课程。");
continue;
}
var entity = existing.GetValueOrDefault(code);
if (entity is not null && !CanManage(entity.CollegeId, entity.Nature))
{
errors.Add(
$"第 {row.RowNumber} 行:课程编码“{code}”已属于无权维护的课程。");
continue;
}
if (entity is null)
{
entity = new Course
{
Code = code,
Name = name,
CollegeId = college.Id
};
db.Courses.Add(entity);
existing[code] = entity;
created++;
}
else
{
updated++;
}
entity.Name = name;
entity.EnglishName = englishName;
entity.CollegeId = college.Id;
entity.CourseCategoryId = category.Id;
entity.Credits = credits.Value;
entity.TotalHours = totalHours.Value;
entity.LectureHours = lectureHours.Value;
entity.PracticeHours = practiceHours.Value;
entity.Nature = nature.Value;
entity.AssessmentMethod = assessment.Value;
entity.Description = description;
entity.IsEnabled = enabled.Value;
entity.SortOrder = sortOrder.Value;
}
return new(created, updated, rows.Count);
}
private IQueryable<Course> ScopedCourses()
{
var scope = currentUserDataScope.Current;
var source = db.Courses.AsQueryable();
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
{
return source.Where(x =>
x.Nature == CourseNature.GeneralRequired ||
x.Nature == CourseNature.GeneralElective ||
x.CollegeId == scope.RestrictedCollegeId);
}
if (scope.Scope == DataScope.Class)
{
return source.Where(course => db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Classes.Any(item =>
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
}
var userId = scope.UserId;
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
var isStudent = scope.IsInRole(SystemRoles.Student);
return source.Where(course =>
isTeacher && db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Teachers.Any(item => item.Teacher!.UserId == userId)) ||
isStudent && db.TeachingTasks.Any(task =>
task.CourseId == course.Id &&
task.Classes.Any(item =>
item.AdministrativeClass!.Students.Any(student =>
student.UserId == userId))));
}
private static IQueryable<Course> ApplyFilters(
IQueryable<Course> source,
string? keyword,
Guid? collegeId,
Guid? categoryId,
CourseNature? nature,
bool? isEnabled)
{
if (collegeId.HasValue)
source = source.Where(x => x.CollegeId == collegeId.Value);
if (categoryId.HasValue)
source = source.Where(x => x.CourseCategoryId == categoryId.Value);
if (nature.HasValue)
source = source.Where(x => x.Nature == nature.Value);
if (isEnabled.HasValue)
source = source.Where(x => x.IsEnabled == isEnabled.Value);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.Code.Contains(keyword) ||
x.Name.Contains(keyword) ||
(x.EnglishName != null && x.EnglishName.Contains(keyword)));
}
return source;
}
private bool CanManage(Guid collegeId, CourseNature nature) =>
CourseMaintenancePolicy.CanManage(
currentUserDataScope.Current,
collegeId,
nature);
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50))
ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static string? Required(
ExcelRow row,
string header,
int maximumLength,
List<string> errors)
{
var value = Optional(row, header, maximumLength, errors);
if (value is not null) return value;
if (string.IsNullOrWhiteSpace(row[header]))
errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。");
return null;
}
private static string? Optional(
ExcelRow row,
string header,
int maximumLength,
List<string> errors)
{
var value = string.IsNullOrWhiteSpace(row[header])
? null
: row[header].Trim();
if (value is null || value.Length <= maximumLength) return value;
errors.Add(
$"第 {row.RowNumber} 行:“{header}”不能超过 {maximumLength} 个字符。");
return null;
}
private static int? ParseInt(
ExcelRow row,
string header,
int minimum,
int maximum,
List<string> errors)
{
if (int.TryParse(
row[header],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var value) &&
value >= minimum &&
value <= maximum)
return value;
errors.Add(
$"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。");
return null;
}
private static decimal? ParseDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
if (decimal.TryParse(
row[header],
NumberStyles.Number,
CultureInfo.InvariantCulture,
out var value) &&
value >= minimum &&
value <= maximum)
return value;
errors.Add(
$"第 {row.RowNumber} 行:“{header}”必须是 {minimum:0.##} 至 {maximum:0.##} 的数值。");
return null;
}
private static CourseNature? ParseNature(ExcelRow row, List<string> errors)
{
var value = row["课程性质"];
if (value == "通识必修" ||
value.Equals(nameof(CourseNature.GeneralRequired), StringComparison.OrdinalIgnoreCase))
return CourseNature.GeneralRequired;
if (value == "专业必修" ||
value.Equals(nameof(CourseNature.MajorRequired), StringComparison.OrdinalIgnoreCase))
return CourseNature.MajorRequired;
if (value == "专业选修" ||
value.Equals(nameof(CourseNature.MajorElective), StringComparison.OrdinalIgnoreCase))
return CourseNature.MajorElective;
if (value == "通识选修" ||
value.Equals(nameof(CourseNature.GeneralElective), StringComparison.OrdinalIgnoreCase))
return CourseNature.GeneralElective;
if (value is "实践" or "实践课程" ||
value.Equals(nameof(CourseNature.Practice), StringComparison.OrdinalIgnoreCase))
return CourseNature.Practice;
errors.Add(
$"第 {row.RowNumber} 行:“课程性质”请填写通识必修、专业必修、专业选修、通识选修或实践课程。");
return null;
}
private static AssessmentMethod? ParseAssessment(
ExcelRow row,
List<string> errors)
{
var value = row["考核方式"];
if (value == "考试" ||
value.Equals(nameof(AssessmentMethod.Examination), StringComparison.OrdinalIgnoreCase))
return AssessmentMethod.Examination;
if (value == "考查" ||
value.Equals(nameof(AssessmentMethod.Assessment), StringComparison.OrdinalIgnoreCase))
return AssessmentMethod.Assessment;
errors.Add($"第 {row.RowNumber} 行:“考核方式”请填写考试或考查。");
return null;
}
private static bool? ParseEnabled(ExcelRow row, List<string> errors)
{
var value = row["启用状态"];
if (value is "是" or "启用" or "1" ||
value.Equals("true", StringComparison.OrdinalIgnoreCase))
return true;
if (value is "否" or "停用" or "0" ||
value.Equals("false", StringComparison.OrdinalIgnoreCase))
return false;
errors.Add($"第 {row.RowNumber} 行:“启用状态”请填写是或否。");
return null;
}
private static IReadOnlyList<object?> Row(params object?[] values) => values;
private static string NatureName(CourseNature value) => value switch
{
CourseNature.GeneralRequired => "通识必修",
CourseNature.MajorRequired => "专业必修",
CourseNature.MajorElective => "专业选修",
CourseNature.GeneralElective => "通识选修",
_ => "实践课程"
};
private static string AssessmentName(AssessmentMethod value) =>
value == AssessmentMethod.Examination ? "考试" : "考查";
}
@@ -168,6 +168,9 @@ public sealed class CurriculumPlansController(
course.Course.Credits,
course.Course.TotalHours,
course.Course.Nature,
CategoryName = course.Course.CourseCategory != null
? course.Course.CourseCategory.Name
: null,
course.RecommendedSemester,
course.Type,
course.Notes
@@ -156,10 +156,6 @@ public sealed class GraduationAuditsController(
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var requiredCourses = plan?.Modules
.SelectMany(x => x.Courses)
.Where(x => x.Type == CurriculumCourseType.Required)
.ToList() ?? [];
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
var passedCourseIds = studentGrades
.Where(IsPassed)
@@ -170,14 +166,12 @@ public sealed class GraduationAuditsController(
.Where(IsPassed)
.GroupBy(x => x.CourseId)
.Sum(x => x.Max(item => item.Credits));
var missingCourses = requiredCourses
.Where(x => !passedCourseIds.Contains(x.CourseId))
.Select(x => x.Course!.Name)
.Distinct()
.ToArray();
var completion = CurriculumCompletionRules.Evaluate(
plan?.Modules ?? [],
passedCourseIds);
var missingCourseNames = plan is null
? "未匹配已发布的培养方案"
: string.Join("、", missingCourses);
: string.Join("、", completion.MissingRequirements);
if (missingCourseNames.Length > 2000)
missingCourseNames = missingCourseNames[..2000];
var failedCourseCount = studentGrades
@@ -188,8 +182,8 @@ public sealed class GraduationAuditsController(
student.Status,
plan?.TotalCredits ?? 0,
earnedCredits,
requiredCourses.Count,
requiredCourses.Count - missingCourses.Length,
completion.RequirementCount,
completion.PassedRequirementCount,
failedCourseCount);
db.GraduationAuditResults.Add(new GraduationAuditResult
{
@@ -199,8 +193,8 @@ public sealed class GraduationAuditsController(
StudentStatusSnapshot = student.Status,
RequiredCredits = plan?.TotalCredits ?? 0,
EarnedCredits = earnedCredits,
RequiredCourseCount = requiredCourses.Count,
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
RequiredCourseCount = completion.RequirementCount,
PassedRequiredCourseCount = completion.PassedRequirementCount,
FailedCourseCount = failedCourseCount,
MissingCourseNames = missingCourseNames,
CalculatedConclusion = conclusion,
@@ -40,6 +40,8 @@ public sealed class Course : CatalogEntity
{
public Guid CollegeId { get; set; }
public College? College { get; set; }
public Guid? CourseCategoryId { get; set; }
public CourseCategory? CourseCategory { get; set; }
public string? EnglishName { get; set; }
public decimal Credits { get; set; }
public int TotalHours { get; set; }
@@ -50,6 +52,10 @@ public sealed class Course : CatalogEntity
public string? Description { get; set; }
}
public sealed class CourseCategory : CatalogEntity
{
}
public enum Gender
{
Unknown = 0,
@@ -0,0 +1,57 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public sealed record CurriculumCompletionSummary(
int RequirementCount,
int PassedRequirementCount,
IReadOnlyList<string> MissingRequirements);
public static class CurriculumCompletionRules
{
public static CurriculumCompletionSummary Evaluate(
IEnumerable<CurriculumModule> modules,
IReadOnlySet<Guid> passedCourseIds)
{
var moduleList = modules.ToList();
var requiredCourses = moduleList
.SelectMany(x => x.Courses)
.Where(x => x.Type == CurriculumCourseType.Required)
.ToList();
var optionGroups = moduleList
.Where(x =>
x.RequiredCredits > 0 &&
x.Courses.Any(course => course.Type == CurriculumCourseType.Elective))
.ToList();
var missing = requiredCourses
.Where(x => !passedCourseIds.Contains(x.CourseId))
.Select(x => x.Course?.Name ?? "未命名必修课程")
.Distinct()
.ToList();
var passedRequiredCourses = requiredCourses.Count -
requiredCourses.Count(x =>
!passedCourseIds.Contains(x.CourseId));
var passedGroups = 0;
foreach (var module in optionGroups)
{
var earnedGroupCredits = module.Courses
.Where(x => passedCourseIds.Contains(x.CourseId))
.GroupBy(x => x.CourseId)
.Sum(group => group.Max(x => x.Course?.Credits ?? 0));
if (earnedGroupCredits >= module.RequiredCredits)
{
passedGroups++;
continue;
}
var shortage = module.RequiredCredits - earnedGroupCredits;
missing.Add($"{module.Name}(还差 {shortage:0.#} 学分)");
}
return new CurriculumCompletionSummary(
requiredCourses.Count + optionGroups.Count,
passedRequiredCourses + passedGroups,
missing);
}
}
@@ -19,6 +19,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
public DbSet<Teacher> Teachers => Set<Teacher>();
public DbSet<Student> Students => Set<Student>();
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
public DbSet<Course> Courses => Set<Course>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
@@ -78,6 +79,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
ConfigureCatalog<Building>(builder);
ConfigureCatalog<Classroom>(builder);
ConfigureCatalog<AcademicTerm>(builder);
ConfigureCatalog<CourseCategory>(builder);
ConfigureCatalog<Course>(builder);
builder.Entity<College>()
@@ -167,10 +169,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Credits).HasPrecision(5, 2);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.HasIndex(x => new { x.CollegeId, x.Nature });
entity.HasIndex(x => x.CourseCategoryId);
entity.HasOne(x => x.College)
.WithMany()
.HasForeignKey(x => x.CollegeId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.CourseCategory)
.WithMany()
.HasForeignKey(x => x.CourseCategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CurriculumPlan>(entity =>
@@ -28,6 +28,7 @@ public sealed class DatabaseInitializer(
await SeedRolesAsync();
await SeedAdministratorAsync();
await SeedCourseCategoriesAsync();
if (environment.IsDevelopment())
{
@@ -108,6 +109,55 @@ public sealed class DatabaseInitializer(
}
}
private async Task SeedCourseCategoriesAsync()
{
var defaults = new[]
{
("BASIC", "基础课程", 10),
("MORAL", "德育课程", 20),
("AESTHETIC", "美育课程", 30),
("LABOR", "劳动教育", 40),
("INNOVATION", "创新创业", 50),
("ENGLISH", "大学英语", 60),
("SPORTS", "大学体育", 70),
("MILITARY", "国防教育", 80),
("MAJOR", "专业教育", 90),
("PRACTICE", "实践教学", 100)
};
var existingCodes = (await db.CourseCategories
.Select(x => x.Code)
.ToListAsync())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (code, name, sortOrder) in defaults)
{
if (existingCodes.Contains(code)) continue;
db.CourseCategories.Add(new CourseCategory
{
Code = code,
Name = name,
SortOrder = sortOrder
});
}
await db.SaveChangesAsync();
var categories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
var uncategorizedCourses = await db.Courses
.Where(x => x.CourseCategoryId == null)
.ToListAsync();
foreach (var course in uncategorizedCourses)
{
var categoryCode = course.Nature switch
{
CourseNature.Practice => "PRACTICE",
CourseNature.MajorRequired or CourseNature.MajorElective => "MAJOR",
_ => "BASIC"
};
course.CourseCategoryId = categories[categoryCode].Id;
}
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentDataAsync()
{
if (!await db.Campuses.AnyAsync())
@@ -242,6 +292,9 @@ public sealed class DatabaseInitializer(
});
}
var courseCategories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
if (!await db.Courses.AnyAsync())
{
db.Courses.AddRange(
@@ -251,6 +304,7 @@ public sealed class DatabaseInitializer(
Name = "程序设计基础",
EnglishName = "Fundamentals of Programming",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
@@ -265,6 +319,7 @@ public sealed class DatabaseInitializer(
Name = "数据结构",
EnglishName = "Data Structures",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 3.5m,
TotalHours = 56,
LectureHours = 40,
@@ -278,6 +333,7 @@ public sealed class DatabaseInitializer(
Name = "软件工程实践",
EnglishName = "Software Engineering Practice",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["PRACTICE"].Id,
Credits = 2,
TotalHours = 48,
LectureHours = 8,
@@ -286,7 +342,6 @@ public sealed class DatabaseInitializer(
AssessmentMethod = AssessmentMethod.Assessment
});
}
await db.SaveChangesAsync();
if (!await db.CurriculumPlans.AnyAsync())
@@ -18,6 +18,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
private const string CourseCategoriesMigration = "20260724_13_course_categories";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -90,6 +91,22 @@ public sealed class DevelopmentSqliteMigrator(
GraduationClearanceMigration,
GraduationClearanceStatements,
cancellationToken);
var courseCategoryColumnExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('Courses')
WHERE name = 'CourseCategoryId'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
CourseCategoriesMigration,
courseCategoryColumnExists
? CourseCategoriesStatements.Where(
statement => !statement.StartsWith(
"ALTER TABLE", StringComparison.OrdinalIgnoreCase))
: CourseCategoriesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -767,4 +784,34 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
];
private static readonly string[] CourseCategoriesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CourseCategories" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseCategories" PRIMARY KEY,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"IsEnabled" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseCategories_Code"
ON "CourseCategories" ("Code");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseCategories_IsEnabled_SortOrder"
ON "CourseCategories" ("IsEnabled", "SortOrder");
""",
"""
ALTER TABLE "Courses" ADD COLUMN "CourseCategoryId" TEXT NULL;
""",
"""
CREATE INDEX IF NOT EXISTS "IX_Courses_CourseCategoryId"
ON "Courses" ("CourseCategoryId");
"""
];
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CourseCategoryId",
table: "Courses",
type: "char(36)",
nullable: true);
migrationBuilder.CreateTable(
name: "CourseCategories",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseCategories", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Courses_CourseCategoryId",
table: "Courses",
column: "CourseCategoryId");
migrationBuilder.CreateIndex(
name: "IX_CourseCategories_Code",
table: "CourseCategories",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseCategories_IsEnabled_SortOrder",
table: "CourseCategories",
columns: new[] { "IsEnabled", "SortOrder" });
migrationBuilder.AddForeignKey(
name: "FK_Courses_CourseCategories_CourseCategoryId",
table: "Courses",
column: "CourseCategoryId",
principalTable: "CourseCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Courses_CourseCategories_CourseCategoryId",
table: "Courses");
migrationBuilder.DropTable(
name: "CourseCategories");
migrationBuilder.DropIndex(
name: "IX_Courses_CourseCategoryId",
table: "Courses");
migrationBuilder.DropColumn(
name: "CourseCategoryId",
table: "Courses");
}
}
}
@@ -329,6 +329,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid>("CollegeId")
.HasColumnType("char(36)");
b.Property<Guid?>("CourseCategoryId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -375,6 +378,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("Code")
.IsUnique();
b.HasIndex("CourseCategoryId");
b.HasIndex("CollegeId", "Nature");
b.HasIndex("IsEnabled", "SortOrder");
@@ -382,6 +387,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("CourseCategories");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.Property<Guid>("Id")
@@ -1948,7 +1991,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory")
.WithMany()
.HasForeignKey("CourseCategoryId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("College");
b.Navigation("CourseCategory");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>