已完善个人教学日历订阅。
主要能力: 教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。 自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。 采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。 支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。 订阅地址按实际前端 API 地址生成,兼容独立部署域名。 已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user