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

主要能力:
教师、学生可在“我的课表”启用订阅、复制 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,