已完善个人教学日历订阅。

主要能力:
教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
订阅地址按实际前端 API 地址生成,兼容独立部署域名。
已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
2026-07-26 17:22:58 +08:00 Unverified
parent ec28eb6c16
commit 40b8cb6e3c
15 changed files with 5631 additions and 9 deletions
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
@@ -16,6 +17,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class TimetablesController(
AppDbContext db,
TimetableDataService timetableDataService,
PersonalCalendarService personalCalendarService,
IAppCache cache) : ControllerBase
{
[HttpGet("options")]
@@ -201,6 +203,101 @@ public sealed class TimetablesController(
return NotFound();
}
[HttpGet("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> BuildTimetableAsync(
Guid classId,
Guid? academicTermId,
@@ -327,8 +424,57 @@ public sealed class TimetablesController(
value = value.Replace(character, '-');
return value.Trim();
}
private async Task<ApplicationUser?> 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<TimetableTermOption> Terms,
IReadOnlyList<TimetableCollegeOption> Colleges,
@@ -10,6 +10,8 @@ public sealed class ApplicationUser : IdentityUser<Guid>
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public string? CalendarSubscriptionStamp { get; set; }
public DateTime? CalendarSubscriptionCreatedAt { get; set; }
}
public sealed class ApplicationRole : IdentityRole<Guid>
@@ -119,6 +119,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.DisplayName).HasMaxLength(50);
entity.Property(x => x.StaffNumber).HasMaxLength(30);
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
entity.HasIndex(x => x.StaffNumber);
});
@@ -50,6 +50,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_27_academic_term_archiving";
private const string CourseSelectionWaitlistMigration =
"20260726_28_course_selection_waitlist";
private const string PersonalCalendarSubscriptionMigration =
"20260726_29_personal_calendar_subscription";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -359,6 +361,21 @@ public sealed class DevelopmentSqliteMigrator(
CourseSelectionWaitlistMigration,
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
cancellationToken);
var personalCalendarSubscriptionExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('AspNetUsers')
WHERE name = 'CalendarSubscriptionStamp'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
PersonalCalendarSubscriptionMigration,
personalCalendarSubscriptionExists
? []
: PersonalCalendarSubscriptionStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1616,6 +1633,18 @@ public sealed class DevelopmentSqliteMigrator(
"""
];
private static readonly string[] PersonalCalendarSubscriptionStatements =
[
"""
ALTER TABLE "AspNetUsers"
ADD COLUMN "CalendarSubscriptionStamp" TEXT NULL;
""",
"""
ALTER TABLE "AspNetUsers"
ADD COLUMN "CalendarSubscriptionCreatedAt" TEXT NULL;
"""
];
private static readonly string[] CourseAdjustmentsStatements =
[
"""
@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PersonalCalendarSubscription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CalendarSubscriptionCreatedAt",
table: "AspNetUsers",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CalendarSubscriptionStamp",
table: "AspNetUsers",
type: "varchar(64)",
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CalendarSubscriptionCreatedAt",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "CalendarSubscriptionStamp",
table: "AspNetUsers");
}
}
}
@@ -3016,6 +3016,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CalendarSubscriptionStamp")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid?>("CollegeId")
.HasColumnType("char(36)");
@@ -0,0 +1,450 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Infrastructure.Timetables;
public sealed class PersonalCalendarService(
AppDbContext db,
TimetableDataService timetableDataService,
IOptions<JwtOptions> jwtOptions)
{
private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8);
private readonly byte[] _signingKey = SHA256.HashData(
Encoding.UTF8.GetBytes($"{jwtOptions.Value.Key}|personal-calendar"));
public static string CreateStamp() =>
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
public string CreateAccessToken(ApplicationUser user)
{
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
throw new InvalidOperationException("个人日历订阅尚未启用。");
var payload = Encoding.UTF8.GetBytes(
$"{user.Id:N}:{user.CalendarSubscriptionStamp}");
using var hmac = new HMACSHA256(_signingKey);
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
}
public bool IsAccessTokenValid(ApplicationUser user, string accessToken)
{
if (!user.IsEnabled || string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
return false;
byte[] provided;
try
{
provided = WebEncoders.Base64UrlDecode(accessToken);
}
catch (FormatException)
{
return false;
}
var expected = WebEncoders.Base64UrlDecode(CreateAccessToken(user));
return provided.Length == expected.Length &&
CryptographicOperations.FixedTimeEquals(provided, expected);
}
public async Task<PersonalCalendarResult?> BuildAsync(
ApplicationUser user,
CancellationToken cancellationToken)
{
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == user.Id && x.Status == StudentStatus.Active)
.Select(x => new
{
x.Id,
x.AdministrativeClassId,
x.StudentNumber,
x.Name
})
.FirstOrDefaultAsync(cancellationToken);
var teacher = student is null
? await db.Teachers.AsNoTracking()
.Where(x => x.UserId == user.Id && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id, x.Name })
.FirstOrDefaultAsync(cancellationToken)
: null;
if (student is null && teacher is null) return null;
var today = DateOnly.FromDateTime(DateTime.UtcNow.Add(ChinaOffset));
var earliestDate = today.AddMonths(-2);
var latestDate = today.AddYears(1);
var termIds = await db.AcademicTerms.AsNoTracking()
.Where(x =>
x.IsEnabled &&
x.EndDate >= earliestDate &&
x.StartDate <= latestDate)
.OrderBy(x => x.StartDate)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var calendarName = $"明序教务 · {user.DisplayName}个人教学日历";
var builder = new IcsBuilder(calendarName);
foreach (var termId in termIds)
{
var timetable = student is not null
? await timetableDataService.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
termId,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
cancellationToken)
: await timetableDataService.BuildAsync(
TimetableResourceType.Teacher,
teacher!.Id,
termId,
null,
false,
null,
null,
cancellationToken);
if (timetable is null) continue;
AddScheduledCourses(builder, timetable);
AddExamEntries(builder, timetable, teacher is not null);
AddFlexibleCourseReminders(builder, timetable);
}
var makeupSessions = await LoadMakeupSessionsAsync(
student?.Id,
teacher?.Id,
earliestDate,
latestDate,
cancellationToken);
foreach (var session in makeupSessions)
{
builder.AddTimedEvent(
$"makeup-{session.Id:N}@jiaowu",
teacher is null
? $"[补考] {session.CourseName}"
: $"[补考监考] {session.CourseName}",
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.StartsAt)),
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.EndsAt)),
JoinLocation(
session.CampusName,
session.BuildingName,
session.ClassroomName),
JoinDescription(
session.PlanName,
session.TaskName,
session.Notes),
"补考",
session.UpdatedAt);
}
return new PersonalCalendarResult(
builder.Build(),
calendarName,
builder.EventCount);
}
private static void AddScheduledCourses(IcsBuilder builder, TimetableData timetable)
{
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
var termMonday = StartOfWeek(timetable.Term.StartDate);
foreach (var entry in timetable.Entries)
{
if (!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
!slots.TryGetValue(
entry.StartPeriod + entry.PeriodCount - 1,
out var endSlot))
continue;
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
{
if (!OccursInWeek(entry.WeekPattern, week)) continue;
var date = termMonday.AddDays((week - 1) * 7 + entry.DayOfWeek - 1);
if (date < timetable.Term.StartDate || date > timetable.Term.EndDate)
continue;
builder.AddTimedEvent(
$"course-{entry.Id:N}-{date:yyyyMMdd}@jiaowu",
entry.CourseName,
ToUtc(date, startSlot.StartsAt),
ToUtc(date, endSlot.EndsAt),
JoinLocation(
entry.CampusName,
entry.BuildingName,
entry.ClassroomName),
JoinDescription(
entry.TaskName,
$"教师:{string.Join('、', entry.TeacherNames)}",
$"教学班:{string.Join('、', entry.ClassNames)}",
entry.Notes),
"课程",
entry.UpdatedAt);
}
}
}
private static void AddExamEntries(
IcsBuilder builder,
TimetableData timetable,
bool isTeacher)
{
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
foreach (var entry in timetable.ExamEntries)
{
if (entry.ExamDate is not DateOnly examDate ||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
!slots.TryGetValue(
entry.StartPeriod + entry.PeriodCount - 1,
out var endSlot))
continue;
builder.AddTimedEvent(
$"exam-{entry.Id:N}@jiaowu",
isTeacher
? $"[监考] {entry.CourseName}"
: $"[考试] {entry.CourseName}",
ToUtc(examDate, startSlot.StartsAt),
ToUtc(examDate, endSlot.EndsAt),
JoinLocation(
entry.CampusName,
entry.BuildingName,
entry.ClassroomName),
JoinDescription(
entry.ExamPlanName,
entry.TaskName,
string.Join('、', entry.TeacherNames),
entry.Notes),
"考试",
entry.UpdatedAt);
}
}
private static void AddFlexibleCourseReminders(
IcsBuilder builder,
TimetableData timetable)
{
foreach (var course in timetable.FlexibleCourses)
{
builder.AddAllDayEvent(
$"flexible-{course.Id:N}-{timetable.Term.Id:N}@jiaowu",
$"[灵活安排] {course.CourseName}",
timetable.Term.StartDate,
JoinDescription(
$"{course.StartWeek}—{course.EndWeek} 周",
$"每周 {course.WeeklyHours} 学时",
$"教师:{string.Join('、', course.TeacherNames)}",
course.Notes),
"灵活课程",
course.UpdatedAt);
}
}
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
Guid? studentId,
Guid? teacherId,
DateOnly earliestDate,
DateOnly latestDate,
CancellationToken cancellationToken)
{
if (!studentId.HasValue && !teacherId.HasValue) return [];
var source = db.MakeupExamSessions.AsNoTracking()
.Where(x =>
x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
x.ExamDate >= earliestDate &&
x.ExamDate <= latestDate);
source = studentId.HasValue
? source.Where(x => x.Enrollments.Any(e => e.StudentId == studentId.Value))
: source.Where(x => x.Invigilators.Any(i => i.TeacherId == teacherId!.Value));
return await source
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.Select(x => new PersonalMakeupSession(
x.Id,
x.MakeupExamPlan!.Name,
x.TeachingTask!.Name,
x.TeachingTask.Course!.Name,
x.ExamDate,
x.StartsAt,
x.EndsAt,
x.Classroom == null ? null : x.Classroom.Name,
x.Classroom == null ? null : x.Classroom.Building!.Name,
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
x.Notes,
x.UpdatedAt))
.ToListAsync(cancellationToken);
}
private static DateOnly StartOfWeek(DateOnly date)
{
var day = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek;
return date.AddDays(1 - day);
}
private static bool OccursInWeek(WeekPattern pattern, int week) => pattern switch
{
WeekPattern.Odd => week % 2 == 1,
WeekPattern.Even => week % 2 == 0,
_ => true
};
private static DateTime ToUtc(DateOnly date, TimeOnly time)
{
var local = date.ToDateTime(time, DateTimeKind.Unspecified);
return new DateTimeOffset(local, ChinaOffset).UtcDateTime;
}
private static string JoinLocation(params string?[] parts) =>
string.Join(" · ", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
private static string JoinDescription(params string?[] parts) =>
string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
private sealed record PersonalMakeupSession(
Guid Id,
string PlanName,
string TaskName,
string CourseName,
DateOnly ExamDate,
DateTime StartsAt,
DateTime EndsAt,
string? ClassroomName,
string? BuildingName,
string? CampusName,
string? Notes,
DateTime UpdatedAt);
}
public sealed record PersonalCalendarResult(
byte[] Content,
string Name,
int EventCount);
internal sealed class IcsBuilder
{
private static readonly UTF8Encoding Utf8 = new(false);
private readonly StringBuilder _content = new();
private bool _built;
public IcsBuilder(string name)
{
AddLine("BEGIN:VCALENDAR");
AddLine("VERSION:2.0");
AddLine("PRODID:-//Mingxu Jiaowu//Personal Teaching Calendar//ZH-CN");
AddLine("CALSCALE:GREGORIAN");
AddLine("METHOD:PUBLISH");
AddLine($"X-WR-CALNAME:{Escape(name)}");
AddLine("X-WR-TIMEZONE:Asia/Shanghai");
AddLine("REFRESH-INTERVAL;VALUE=DURATION:PT1H");
AddLine("X-PUBLISHED-TTL:PT1H");
}
public int EventCount { get; private set; }
public void AddTimedEvent(
string uid,
string summary,
DateTime startsAtUtc,
DateTime endsAtUtc,
string location,
string description,
string category,
DateTime updatedAt)
{
AddLine("BEGIN:VEVENT");
AddLine($"UID:{uid}");
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
AddLine($"DTSTART:{FormatUtc(startsAtUtc)}");
AddLine($"DTEND:{FormatUtc(endsAtUtc)}");
AddLine($"SUMMARY:{Escape(summary)}");
if (!string.IsNullOrWhiteSpace(location))
AddLine($"LOCATION:{Escape(location)}");
if (!string.IsNullOrWhiteSpace(description))
AddLine($"DESCRIPTION:{Escape(description)}");
AddLine($"CATEGORIES:{Escape(category)}");
AddLine("STATUS:CONFIRMED");
AddLine("END:VEVENT");
EventCount++;
}
public void AddAllDayEvent(
string uid,
string summary,
DateOnly date,
string description,
string category,
DateTime updatedAt)
{
AddLine("BEGIN:VEVENT");
AddLine($"UID:{uid}");
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
AddLine($"DTSTART;VALUE=DATE:{date:yyyyMMdd}");
AddLine($"DTEND;VALUE=DATE:{date.AddDays(1):yyyyMMdd}");
AddLine($"SUMMARY:{Escape(summary)}");
if (!string.IsNullOrWhiteSpace(description))
AddLine($"DESCRIPTION:{Escape(description)}");
AddLine($"CATEGORIES:{Escape(category)}");
AddLine("STATUS:CONFIRMED");
AddLine("TRANSP:TRANSPARENT");
AddLine("END:VEVENT");
EventCount++;
}
public byte[] Build()
{
if (!_built)
{
AddLine("END:VCALENDAR");
_built = true;
}
return Utf8.GetBytes(_content.ToString());
}
private void AddLine(string line)
{
var current = new StringBuilder();
var byteCount = 0;
foreach (var rune in line.EnumerateRunes())
{
var value = rune.ToString();
var runeBytes = Utf8.GetByteCount(value);
if (byteCount > 0 && byteCount + runeBytes > 74)
{
_content.Append(current).Append("\r\n ");
current.Clear();
byteCount = 1;
}
current.Append(value);
byteCount += runeBytes;
}
_content.Append(current).Append("\r\n");
}
private static string Escape(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\r\n", "\\n", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace(",", "\\,", StringComparison.Ordinal);
private static string FormatUtc(DateTime value) =>
value.ToUniversalTime().ToString(
"yyyyMMdd'T'HHmmss'Z'",
CultureInfo.InvariantCulture);
private static DateTime NormalizeUpdatedAt(DateTime value) =>
value == default
? new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
: value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
: value.ToUniversalTime();
}
@@ -99,7 +99,11 @@ public sealed class TimetableDataService(AppDbContext db)
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.Notes))
x.Notes,
false,
null,
null,
x.UpdatedAt))
.ToListAsync(cancellationToken);
}
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Classes.Select(item => item.AdministrativeClass!.Name),
x.Notes))
x.Notes,
x.UpdatedAt))
.ToListAsync(cancellationToken);
}
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
x.ExamPlan!.Name,
InvigilatorNames = x.Invigilators
.Select(i => i.Teacher!.Name),
x.Notes
x.Notes,
x.UpdatedAt
})
.ToListAsync(cancellationToken);
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
x.Notes,
true,
x.Name,
x.ExamDate);
x.ExamDate,
x.UpdatedAt);
}).ToList();
}
}
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
string? Notes,
bool IsExam = false,
string? ExamPlanName = null,
DateOnly? ExamDate = null);
DateOnly? ExamDate = null,
DateTime UpdatedAt = default);
public sealed record FlexibleCourseDto(
Guid Id,
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
int WeeklyHours,
IEnumerable<string> TeacherNames,
IEnumerable<string> ClassNames,
string? Notes);
string? Notes,
DateTime UpdatedAt = default);
+1
View File
@@ -200,6 +200,7 @@ builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Jiaowu.Api.Tests")]