Implement requested Jiaowu workflow updates

This commit is contained in:
biss committed 2026-08-18 16:32:18 +08:00
1 parent 2257ce7674
commit 3450d882cf
20 files changed
+16416 -7

No files matched your search

@@ -0,0 +1,164 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.AcademicCalendar;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/academic-calendar")]
public sealed class AcademicCalendarController(AppDbContext db, HolidayCnClient holidayCnClient) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet]
public async Task<ActionResult> GetMyCalendar(Guid? academicTermId, CancellationToken cancellationToken)
{
var roles = User.FindAll(ClaimTypes.Role)
.Select(claim => claim.Value)
.ToHashSet(StringComparer.Ordinal);
var events = db.AcademicCalendarEvents.AsNoTracking().Where(x => x.IsEnabled);
if (academicTermId.HasValue)
events = events.Where(x => x.AcademicTermId == academicTermId.Value);
else
events = events.Where(x => x.AcademicTerm!.IsCurrent);
var items = await events
.Include(x => x.AcademicTerm)
.OrderBy(x => x.StartsOn)
.ToListAsync(cancellationToken);
return Ok(items.Where(item => IsForRoles(item.AudienceRoles, roles))
.Select(ToResponse));
}
[HttpGet("manage")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAll(Guid? academicTermId, CancellationToken cancellationToken)
{
var query = db.AcademicCalendarEvents.AsNoTracking().Include(x => x.AcademicTerm).AsQueryable();
if (academicTermId.HasValue)
query = query.Where(x => x.AcademicTermId == academicTermId.Value);
return Ok((await query.OrderBy(x => x.StartsOn).ToListAsync(cancellationToken)).Select(ToResponse));
}
[HttpPost]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(AcademicCalendarEventRequest request, CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var item = new AcademicCalendarEvent { AcademicTermId = request.AcademicTermId, Title = request.Title.Trim(), Kind = request.Kind, StartsOn = request.StartsOn, EndsOn = request.EndsOn, ReminderDays = request.ReminderDays, AudienceRoles = SerializeRoles(request.AudienceRoles), Description = request.Description?.Trim(), IsEnabled = request.IsEnabled };
db.AcademicCalendarEvents.Add(item);
await db.SaveChangesAsync(cancellationToken);
await db.Entry(item).Reference(x => x.AcademicTerm).LoadAsync(cancellationToken);
return CreatedAtAction(nameof(GetAll), new { academicTermId = item.AcademicTermId }, ToResponse(item));
}
[HttpPut("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Update(Guid id, AcademicCalendarEventRequest request, CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var item = await db.AcademicCalendarEvents.Include(x => x.AcademicTerm).FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (item is null) return NotFound();
item.AcademicTermId = request.AcademicTermId; item.Title = request.Title.Trim(); item.Kind = request.Kind; item.StartsOn = request.StartsOn; item.EndsOn = request.EndsOn; item.ReminderDays = request.ReminderDays; item.AudienceRoles = SerializeRoles(request.AudienceRoles); item.Description = request.Description?.Trim(); item.IsEnabled = request.IsEnabled;
await db.SaveChangesAsync(cancellationToken);
item.AcademicTerm = await db.AcademicTerms.AsNoTracking()
.FirstAsync(x => x.Id == item.AcademicTermId, cancellationToken);
return Ok(ToResponse(item));
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var item = await db.AcademicCalendarEvents.FindAsync([id], cancellationToken);
if (item is null) return NotFound();
db.AcademicCalendarEvents.Remove(item);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("holidays/{year:int}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PreviewHolidays(int year, CancellationToken cancellationToken)
{
if (year is < 2000 or > 2100) return ValidationProblem("年份必须在 2000 至 2100 之间。");
try
{
var result = await holidayCnClient.GetYearAsync(year, cancellationToken);
return Ok(new { result.Year, result.Papers, Days = result.Days.Select(day => new { day.Name, day.Date, day.IsOffDay }) });
}
catch (HttpRequestException)
{
return Problem("无法获取节假日数据,请检查网络后重试。", statusCode: StatusCodes.Status502BadGateway);
}
catch (InvalidOperationException exception)
{
return ValidationProblem(exception.Message);
}
}
[HttpPut("holidays/sync")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SyncHolidays(HolidaySyncRequest request, CancellationToken cancellationToken)
{
if (request.Year is < 2000 or > 2100) return ValidationProblem("年份必须在 2000 至 2100 之间。");
if (!await db.AcademicTerms.AnyAsync(item => item.Id == request.AcademicTermId && item.IsEnabled, cancellationToken)) return ValidationProblem("学期不存在或已停用。");
HolidayCnYear source;
try { source = await holidayCnClient.GetYearAsync(request.Year, cancellationToken); }
catch (HttpRequestException) { return Problem("无法获取节假日数据,请检查网络后重试。", statusCode: StatusCodes.Status502BadGateway); }
catch (InvalidOperationException exception) { return ValidationProblem(exception.Message); }
var existing = await db.AcademicCalendarEvents.Where(item => item.AcademicTermId == request.AcademicTermId && item.Source == AcademicCalendarEventSource.HolidayCn && item.SourceYear == request.Year).ToListAsync(cancellationToken);
db.AcademicCalendarEvents.RemoveRange(existing);
db.AcademicCalendarEvents.AddRange(source.Days.Select(day => new AcademicCalendarEvent
{
AcademicTermId = request.AcademicTermId,
Title = day.Name,
Kind = AcademicCalendarEventKind.PublicHoliday,
StartsOn = day.Date,
ReminderDays = day.IsOffDay ? 3 : 0,
Description = day.IsOffDay ? "法定节假日或调休休息日" : "调休上班日",
Source = AcademicCalendarEventSource.HolidayCn,
SourceYear = request.Year,
IsOffDay = day.IsOffDay
}));
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Year = request.Year, SyncedCount = source.Days.Count, ReplacedCount = existing.Count, source.Papers });
}
private async Task<ActionResult?> ValidateAsync(AcademicCalendarEventRequest request, CancellationToken cancellationToken)
{
if (request.EndsOn.HasValue && request.EndsOn < request.StartsOn) return ValidationProblem("结束日期不能早于开始日期。");
if (!await db.AcademicTerms.AnyAsync(x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("学期不存在或已停用。");
if (request.AudienceRoles.Any(role => !KnownRoles.Contains(role))) return ValidationProblem("包含无效的目标角色。");
return null;
}
private static readonly HashSet<string> KnownRoles = [SystemRoles.SuperAdmin, SystemRoles.AcademicAdmin, SystemRoles.CollegeAdmin, SystemRoles.Leader, SystemRoles.Counselor, SystemRoles.Teacher, SystemRoles.Student, SystemRoles.MaintenanceAdmin, SystemRoles.MaintenanceStaff];
private static bool IsForRoles(string? audienceRoles, HashSet<string> roles) => string.IsNullOrWhiteSpace(audienceRoles) || audienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Any(roles.Contains);
private static string? SerializeRoles(IReadOnlyCollection<string> roles) => roles.Count == 0 ? null : string.Join(',', roles.Distinct().Order());
private static object ToResponse(AcademicCalendarEvent item) => new { item.Id, item.AcademicTermId, AcademicTermName = item.AcademicTerm?.Name, item.Title, item.Kind, item.StartsOn, item.EndsOn, item.ReminderDays, AudienceRoles = string.IsNullOrWhiteSpace(item.AudienceRoles) ? Array.Empty<string>() : item.AudienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), item.Description, item.IsEnabled, item.Source, item.SourceYear, item.IsOffDay };
}
public sealed record AcademicCalendarEventRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Title,
AcademicCalendarEventKind Kind,
DateOnly StartsOn,
DateOnly? EndsOn,
[Range(0, 90)] int ReminderDays,
IReadOnlyCollection<string> AudienceRoles,
[MaxLength(1000)] string? Description,
bool IsEnabled = true);
public sealed record HolidaySyncRequest(Guid AcademicTermId, int Year);
@@ -0,0 +1,40 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
/// <summary>
/// 校历中的独立日程。它仅用于展示和提醒,不参与任何业务流程的状态判断或截止限制。
/// </summary>
public sealed class AcademicCalendarEvent : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public required string Title { get; set; }
public AcademicCalendarEventKind Kind { get; set; } = AcademicCalendarEventKind.Other;
public DateOnly StartsOn { get; set; }
public DateOnly? EndsOn { get; set; }
public int ReminderDays { get; set; } = 7;
public string? AudienceRoles { get; set; }
public string? Description { get; set; }
public bool IsEnabled { get; set; } = true;
public AcademicCalendarEventSource Source { get; set; } = AcademicCalendarEventSource.Manual;
public int? SourceYear { get; set; }
public bool? IsOffDay { get; set; }
}
public enum AcademicCalendarEventKind
{
CourseSelection = 1,
Grade = 2,
Evaluation = 3,
Exam = 4,
GraduationAudit = 5,
PublicHoliday = 6,
Other = 99
}
public enum AcademicCalendarEventSource
{
Manual = 1,
HolidayCn = 2
}
@@ -0,0 +1,23 @@
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.AcademicCalendar;
public sealed class HolidayCnClient(HttpClient httpClient)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<HolidayCnYear> GetYearAsync(int year, CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync($"{year}.json", cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<HolidayCnYear>(JsonOptions, cancellationToken);
if (payload is null || payload.Year != year || payload.Days.Count == 0)
throw new InvalidOperationException("节假日数据格式无效或该年份暂无数据。");
if (payload.Days.Any(day => string.IsNullOrWhiteSpace(day.Name) || day.Date.Year != year))
throw new InvalidOperationException("节假日数据包含无效日期。");
return payload;
}
}
public sealed record HolidayCnYear(int Year, IReadOnlyList<string> Papers, IReadOnlyList<HolidayCnDay> Days);
public sealed record HolidayCnDay(string Name, DateOnly Date, bool IsOffDay);
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff. Show more