Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CoursesExcelController.cs
T
biss 77dfa8145c 主要变更:
新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12)
接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153)
缓存学生激活选项、基础数据、公开课表和课表选项。
个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。
基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。
新增 /health/cache,Redis 故障不影响 /health/ready。
Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。
更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
2026-07-26 14:42:27 +08:00

495 lines
18 KiB
C#

using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
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,
IAppCache cache) : 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 中没有可导入的课程数据。");
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
var errors = new List<string>();
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);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
}
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 ? "考试" : "考查";
}