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 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> Import( IFormFile file, CancellationToken cancellationToken) { IReadOnlyList 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>( async transaction => { var errors = new List(); 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 ImportRowsAsync( IReadOnlyList rows, List 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(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 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 ApplyFilters( IQueryable 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 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 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 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 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 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 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 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 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 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 ? "考试" : "考查"; }