已完善个人教学日历订阅。
主要能力: 教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。 自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。 采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。 支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。 订阅地址按实际前端 API 地址生成,兼容独立部署域名。 已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
@@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class MySqlMigrationTests
|
||||
{
|
||||
private const string LatestMigration =
|
||||
"20260726180000_CourseSelectionGradeLimits";
|
||||
"20260726190000_PersonalCalendarSubscription";
|
||||
|
||||
[Fact]
|
||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||
@@ -46,6 +46,12 @@ public sealed class MySqlMigrationTests
|
||||
Assert.Contains(
|
||||
"CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"ADD `CalendarSubscriptionStamp` varchar(64) NULL",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
|
||||
script);
|
||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||
Assert.Contains("DEFAULT 1", script);
|
||||
Assert.DoesNotContain("0001-01-01", script);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PersonalCalendarServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("Data Source=:memory:");
|
||||
private AppDbContext _db = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
_db = new AppDbContext(options);
|
||||
await _db.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_creates_live_teacher_feed_with_courses_and_exams()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday
|
||||
? 6
|
||||
: (int)today.DayOfWeek - 1;
|
||||
var termStart = today.AddDays(-mondayOffset);
|
||||
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||
var college = new College
|
||||
{
|
||||
Code = "CS",
|
||||
Name = "计算机学院",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "calendar-teacher",
|
||||
NormalizedUserName = "CALENDAR-TEACHER",
|
||||
DisplayName = "陈老师",
|
||||
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(),
|
||||
CalendarSubscriptionCreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T-CALENDAR",
|
||||
Name = "陈老师",
|
||||
CollegeId = college.Id,
|
||||
UserId = user.Id,
|
||||
Status = TeacherStatus.Active
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101-CALENDAR",
|
||||
Name = "程序设计基础",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "CALENDAR-TERM",
|
||||
Name = "个人日历测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = termStart,
|
||||
EndDate = termStart.AddDays(40),
|
||||
IsCurrent = true
|
||||
};
|
||||
var building = new Building
|
||||
{
|
||||
Code = "J-CALENDAR",
|
||||
Name = "第一教学楼",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
var classroom = new Classroom
|
||||
{
|
||||
Code = "J-CALENDAR-101",
|
||||
Name = "101",
|
||||
BuildingId = building.Id,
|
||||
Capacity = 60
|
||||
};
|
||||
_db.AddRange(
|
||||
campus,
|
||||
college,
|
||||
user,
|
||||
teacher,
|
||||
course,
|
||||
term,
|
||||
building,
|
||||
classroom);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var scheduledTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "CALENDAR-TASK-01",
|
||||
Name = "程序设计基础教学班",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true }
|
||||
]
|
||||
};
|
||||
var flexibleTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "CALENDAR-TASK-02",
|
||||
Name = "程序设计实践",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
SchedulingMode = TeachingTaskSchedulingMode.Flexible,
|
||||
StartWeek = 1,
|
||||
EndWeek = 4,
|
||||
WeeklyHours = 2,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true }
|
||||
]
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "正式课表",
|
||||
Version = "V1",
|
||||
Status = SchedulePlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.AddRange(
|
||||
scheduledTask,
|
||||
flexibleTask,
|
||||
plan,
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = 1,
|
||||
Name = "第一节",
|
||||
StartsAt = new TimeOnly(8, 0),
|
||||
EndsAt = new TimeOnly(8, 45)
|
||||
},
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = 2,
|
||||
Name = "第二节",
|
||||
StartsAt = new TimeOnly(8, 55),
|
||||
EndsAt = new TimeOnly(9, 40)
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.ScheduleEntries.Add(new ScheduleEntry
|
||||
{
|
||||
SchedulePlanId = plan.Id,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 2,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
var examDate = termStart.AddDays(5);
|
||||
var examPlan = new ExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "期末考试",
|
||||
Status = ExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
var exam = new ExamSession
|
||||
{
|
||||
ExamPlan = examPlan,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = examDate,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
|
||||
EndsAt = examDate.ToDateTime(new TimeOnly(9, 40)),
|
||||
Invigilators =
|
||||
[
|
||||
new ExamSessionInvigilator { TeacherId = teacher.Id }
|
||||
]
|
||||
};
|
||||
var makeupPlan = new MakeupExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "补考安排",
|
||||
Status = MakeupExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
var makeupDate = termStart.AddDays(12);
|
||||
var makeup = new MakeupExamSession
|
||||
{
|
||||
MakeupExamPlan = makeupPlan,
|
||||
TeachingTaskId = scheduledTask.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
ExamDate = makeupDate,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = makeupDate.ToDateTime(new TimeOnly(8, 0)),
|
||||
EndsAt = makeupDate.ToDateTime(new TimeOnly(9, 40)),
|
||||
Invigilators =
|
||||
[
|
||||
new MakeupExamSessionInvigilator { TeacherId = teacher.Id }
|
||||
]
|
||||
};
|
||||
_db.AddRange(exam, makeup);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var service = CreateService();
|
||||
var result = await service.BuildAsync(user, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5, result.EventCount);
|
||||
var ics = Encoding.UTF8.GetString(result.Content);
|
||||
Assert.Contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H", ics);
|
||||
Assert.Contains("SUMMARY:程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[监考] 程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[补考监考] 程序设计基础", ics);
|
||||
Assert.Contains("SUMMARY:[灵活安排] 程序设计基础", ics);
|
||||
Assert.Contains(
|
||||
$"DTSTART:{termStart:yyyyMMdd}T000000Z",
|
||||
ics);
|
||||
Assert.All(
|
||||
ics.Split("\r\n", StringSplitOptions.RemoveEmptyEntries),
|
||||
line => Assert.True(
|
||||
Encoding.UTF8.GetByteCount(line) <= 75,
|
||||
$"ICS line exceeds 75 octets: {line}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
||||
{
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "calendar-user",
|
||||
DisplayName = "订阅用户",
|
||||
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp()
|
||||
};
|
||||
var service = CreateService();
|
||||
var originalToken = service.CreateAccessToken(user);
|
||||
|
||||
Assert.True(service.IsAccessTokenValid(user, originalToken));
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken + "x"));
|
||||
|
||||
user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp();
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken));
|
||||
Assert.True(service.IsAccessTokenValid(user, service.CreateAccessToken(user)));
|
||||
|
||||
user.CalendarSubscriptionStamp = null;
|
||||
Assert.False(service.IsAccessTokenValid(user, originalToken));
|
||||
}
|
||||
|
||||
private PersonalCalendarService CreateService() => new(
|
||||
_db,
|
||||
new TimetableDataService(_db),
|
||||
Options.Create(new JwtOptions
|
||||
{
|
||||
Key = "calendar-test-signing-key-with-at-least-thirty-two-characters"
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user