using System.Security.Claims; using System.Security.Cryptography; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Timetables; 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, TimetableDataService timetableDataService, PersonalCalendarService personalCalendarService, IAppCache cache) : ControllerBase { [HttpGet("options")] [AllowAnonymous] public async Task GetOptions(CancellationToken cancellationToken) { var result = await cache.GetOrCreateAsync( AppCacheKeys.TimetableOptions, LoadOptionsAsync, AppCacheProfile.ReferenceData, [ AppCacheTags.BaseData, AppCacheTags.Timetables, AppCacheTags.TimetableOptions ], cancellationToken); return Ok(result); } [HttpGet("classes/{classId:guid}")] [AllowAnonymous] public Task GetClassTimetable( Guid classId, Guid? academicTermId, CancellationToken cancellationToken) => BuildTimetableAsync(classId, academicTermId, null, cancellationToken); [HttpGet("teachers/{teacherId:guid}")] [AllowAnonymous] public async Task GetTeacherTimetable( Guid teacherId, Guid? academicTermId, CancellationToken cancellationToken) { var result = await GetPublishedTimetableAsync( TimetableResourceType.Teacher, teacherId, academicTermId, cancellationToken); return result is null ? NotFound() : Ok(result); } [HttpGet("teachers/{teacherId:guid}/export.xlsx")] [AllowAnonymous] public async Task ExportTeacherTimetable( Guid teacherId, Guid? academicTermId, CancellationToken cancellationToken) { var result = await GetPublishedTimetableAsync( TimetableResourceType.Teacher, teacherId, academicTermId, cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); } [HttpGet("classes/{classId:guid}/export.xlsx")] [AllowAnonymous] public async Task ExportClassTimetable( Guid classId, Guid? academicTermId, CancellationToken cancellationToken) { var result = await GetPublishedTimetableAsync( TimetableResourceType.Class, classId, academicTermId, cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task 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 not null) { return await BuildTimetableAsync( student.AdministrativeClassId, academicTermId, student.Id, cancellationToken, new TimetableStudentDto(student.StudentNumber, student.Name)); } var teacher = await db.Teachers.AsNoTracking() .Where(x => x.UserId == userId && x.Status == TeacherStatus.Active) .Select(x => new { x.Id }) .FirstOrDefaultAsync(cancellationToken); if (teacher is not null) { var result = await timetableDataService.BuildAsync( TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null, cancellationToken); return result is null ? NotFound() : Ok(result); } return Conflict(new ProblemDetails { Title = "档案未关联", Detail = "当前登录账号没有关联教师或学生档案,请联系教务管理员。", Status = StatusCodes.Status409Conflict }); } [HttpGet("mine/export.xlsx")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task ExportMyTimetable( 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 not null) { var result = await timetableDataService.BuildAsync( TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, null, false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name), cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); } var teacher = await db.Teachers.AsNoTracking() .Where(x => x.UserId == userId && x.Status == TeacherStatus.Active) .Select(x => new { x.Id }) .FirstOrDefaultAsync(cancellationToken); if (teacher is not null) { var result = await timetableDataService.BuildAsync( TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null, cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); } return NotFound(); } [HttpGet("mine/calendar-subscription")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task GetMyCalendarSubscription( CancellationToken cancellationToken) { var user = await CurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); return Ok(BuildCalendarSubscriptionResponse(user)); } [HttpPost("mine/calendar-subscription")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task EnableMyCalendarSubscription( CancellationToken cancellationToken) { var user = await CurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp)) { user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(); user.CalendarSubscriptionCreatedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); } return Ok(BuildCalendarSubscriptionResponse(user)); } [HttpPost("mine/calendar-subscription/rotate")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task RotateMyCalendarSubscription( CancellationToken cancellationToken) { var user = await CurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(); user.CalendarSubscriptionCreatedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return Ok(BuildCalendarSubscriptionResponse(user)); } [HttpDelete("mine/calendar-subscription")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task DisableMyCalendarSubscription( CancellationToken cancellationToken) { var user = await CurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); user.CalendarSubscriptionStamp = null; user.CalendarSubscriptionCreatedAt = null; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpGet("mine/calendar.ics")] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] public async Task DownloadMyCalendar( CancellationToken cancellationToken) { var user = await CurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); var result = await personalCalendarService.BuildAsync(user, cancellationToken); if (result is null) return CalendarProfileNotFound(); return File( result.Content, "text/calendar; charset=utf-8", $"{SafeFileName(user.DisplayName)}-个人教学日历.ics"); } [HttpGet("calendar/{userId:guid}/{accessToken}.ics")] [AllowAnonymous] public async Task GetCalendarFeed( Guid userId, string accessToken, CancellationToken cancellationToken) { var user = await db.Users.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); if (user is null || !personalCalendarService.IsAccessTokenValid(user, accessToken)) return NotFound(); var result = await personalCalendarService.BuildAsync(user, cancellationToken); if (result is null) return NotFound(); var etag = $"\"{Convert.ToHexString(SHA256.HashData(result.Content))}\""; if (Request.Headers.IfNoneMatch.ToString() .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Contains(etag, StringComparer.Ordinal)) { return StatusCode(StatusCodes.Status304NotModified); } Response.Headers.CacheControl = "private, max-age=300"; Response.Headers.ETag = etag; Response.Headers.Append("X-Content-Type-Options", "nosniff"); return File(result.Content, "text/calendar; charset=utf-8"); } private async Task BuildTimetableAsync( Guid classId, Guid? academicTermId, Guid? studentId, CancellationToken cancellationToken, TimetableStudentDto? student = null) { var result = studentId.HasValue || student is not null ? await timetableDataService.BuildAsync( TimetableResourceType.Class, classId, academicTermId, null, false, studentId, student, cancellationToken) : await GetPublishedTimetableAsync( TimetableResourceType.Class, classId, academicTermId, cancellationToken); return result is null ? NotFound() : Ok(result); } private Task GetPublishedTimetableAsync( TimetableResourceType resourceType, Guid resourceId, Guid? academicTermId, CancellationToken cancellationToken) => cache.GetOrCreateAsync( AppCacheKeys.PublishedTimetable( resourceType.ToString().ToLowerInvariant(), resourceId, academicTermId), token => timetableDataService.BuildAsync( resourceType, resourceId, academicTermId, null, false, null, null, token), AppCacheProfile.PublishedTimetable, [AppCacheTags.BaseData, AppCacheTags.Timetables], cancellationToken); private async Task LoadOptionsAsync( CancellationToken cancellationToken) { var terms = await db.AcademicTerms.AsNoTracking() .Where(x => x.IsEnabled) .OrderByDescending(x => x.StartDate) .Select(x => new TimetableTermOption( x.Id, x.Name, x.AcademicYear, x.Season, x.StartDate, x.EndDate, x.IsCurrent, x.IsArchived, db.SchedulePlans.Any(plan => plan.AcademicTermId == x.Id && plan.Status == SchedulePlanStatus.Published) || db.TeachingTasks.Any(task => task.AcademicTermId == x.Id && task.Status == TeachingTaskStatus.Published && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) || db.ExamPlans.Any(plan => plan.AcademicTermId == x.Id && plan.Status == ExamPlanStatus.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 TimetableClassOption( x.Id, x.Code, x.Name, x.Grade, x.MajorId, x.Major!.Name, x.Major.CollegeId, x.Major.College!.Name, 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)) || db.TeachingTasks.Any(task => task.AcademicTermId == defaultTermId.Value && task.Status == TeachingTaskStatus.Published && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && task.Classes.Any(item => item.AdministrativeClassId == x.Id)) || db.ExamSessions.Any(session => session.ExamPlan!.AcademicTermId == defaultTermId.Value && session.ExamPlan.Status == ExamPlanStatus.Published && session.TeachingTask!.Classes.Any(item => item.AdministrativeClassId == x.Id))))) .ToListAsync(cancellationToken); var colleges = await db.Colleges.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.Code) .Select(x => new TimetableCollegeOption(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 TimetableMajorOption( x.Id, x.Code, x.Name, x.CollegeId)) .ToListAsync(cancellationToken); return new TimetableOptionsResponse(terms, colleges, majors, classes); } private ActionResult ExcelFile(TimetableData result) { var bytes = TimetableExcelExporter.Create(result); var fileName = $"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.xlsx"; return File(bytes, ExcelWorkbookHelper.ContentType, fileName); } private static string SafeFileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '-'); return value.Trim(); } private async Task CurrentUserAsync( CancellationToken cancellationToken) { if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return null; return await db.Users.FirstOrDefaultAsync( x => x.Id == userId && x.IsEnabled, cancellationToken); } private PersonalCalendarSubscriptionResponse BuildCalendarSubscriptionResponse( ApplicationUser user) { if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp)) return new(false, null, null, null, null); var accessToken = personalCalendarService.CreateAccessToken(user); var feedPath = $"timetables/calendar/{user.Id:N}/{accessToken}.ics"; var feedUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}" + $"/api/{feedPath}"; var webcalUrl = feedUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? $"webcal://{feedUrl[8..]}" : feedUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ? $"webcal://{feedUrl[7..]}" : feedUrl; return new( true, feedUrl, feedPath, webcalUrl, user.CalendarSubscriptionCreatedAt); } private ActionResult CalendarProfileNotFound() => Conflict(new ProblemDetails { Title = "档案未关联", Detail = "当前登录账号没有关联有效的教师或学生档案,请联系教务管理员。", Status = StatusCodes.Status409Conflict }); } public sealed record PersonalCalendarSubscriptionResponse( bool IsEnabled, string? FeedUrl, string? FeedPath, string? WebcalUrl, DateTime? CreatedAt); public sealed record TimetableOptionsResponse( IReadOnlyList Terms, IReadOnlyList Colleges, IReadOnlyList Majors, IReadOnlyList Classes); public sealed record TimetableTermOption( Guid Id, string Name, string AcademicYear, TermSeason Season, DateOnly StartDate, DateOnly EndDate, bool IsCurrent, bool IsArchived, bool HasPublishedTimetable); public sealed record TimetableCollegeOption(Guid Id, string Code, string Name); public sealed record TimetableMajorOption( Guid Id, string Code, string Name, Guid CollegeId); public sealed record TimetableClassOption( Guid Id, string Code, string Name, int Grade, Guid MajorId, string MajorName, Guid CollegeId, string CollegeName, bool HasPublishedTimetable);