using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using ClosedXML.Excel; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/attendance")] public sealed class AttendanceController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string AttendanceRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor + "," + SystemRoles.Teacher; [HttpGet("my-tasks")] [Authorize(Roles = AttendanceRoles)] public async Task GetMyTasks( Guid? academicTermId, CancellationToken cancellationToken) { var tasks = AccessibleTasks().AsNoTracking() .Where(x => x.Status == TeachingTaskStatus.Published); if (academicTermId.HasValue) tasks = tasks.Where(x => x.AcademicTermId == academicTermId); var result = await tasks .OrderBy(x => x.Course!.Code) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, CourseCode = x.Course!.Code, CourseName = x.Course!.Name, StudentCount = db.Students.Count(student => (student.Status == StudentStatus.Active && x.Classes.Any(assignment => assignment.AdministrativeClassId == student.AdministrativeClassId)) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)), SheetCount = db.AttendanceSheets.Count(sheet => sheet.TeachingTaskId == x.Id) }) .ToListAsync(cancellationToken); return Ok(result); } [HttpGet("sheets")] [Authorize(Roles = AttendanceRoles)] public async Task GetSheets( Guid teachingTaskId, CancellationToken cancellationToken) { var task = await AccessibleTasks().AsNoTracking() .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); if (task is null) return NotFound(); var sheets = await db.AttendanceSheets.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId) .OrderByDescending(x => x.AttendanceDate) .Select(x => new { x.Id, x.Name, x.AttendanceDate, x.Status, x.CheckInMethod, x.CheckInStartsAt, x.CheckInEndsAt, x.Notes, x.SubmittedAt, CheckedInCount = x.Records.Count(r => r.CheckInAt != null), PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present), AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent), LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late), LeaveCount = x.Records.Count(r => r.Status == AttendanceStatus.Leave), ExcusedCount = x.Records.Count(r => r.Status == AttendanceStatus.Excused), TotalCount = x.Records.Count }) .ToListAsync(cancellationToken); return Ok(sheets); } [HttpPost("sheets")] [Authorize(Roles = AttendanceRoles)] public async Task CreateSheet( AttendanceSheetRequest request, CancellationToken cancellationToken) { var task = await AccessibleTasks().AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); if (task is null) return NotFound(); var studentIds = await TeachingTaskRosterQuery .ForTask(db, request.TeachingTaskId) .AsNoTracking() .Select(x => x.Id) .ToListAsync(cancellationToken); if (studentIds.Count == 0) return ConflictProblem("该教学班没有有效学生。"); var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual; if (!Enum.IsDefined(checkInMethod)) return ConflictProblem("不支持该签到方式。"); var now = DateTime.UtcNow; DateTime? checkInEndsAt = null; string? checkInToken = null; if (checkInMethod != AttendanceCheckInMethod.Manual) { if (request.CheckInDurationMinutes is < 1 or > 180) return ConflictProblem("签到时长应为 1 至 180 分钟。"); checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value); } if (checkInMethod == AttendanceCheckInMethod.QrCode) checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); if (checkInMethod == AttendanceCheckInMethod.Location) { if (request.TargetLatitude is < -90 or > 90 || request.TargetLongitude is < -180 or > 180 || request.TargetLatitude is null || request.TargetLongitude is null) return ConflictProblem("请获取有效的签到位置。"); if (request.LocationRadiusMeters is < 20 or > 1000) return ConflictProblem("定位签到范围应为 20 至 1000 米。"); } var sheet = new AttendanceSheet { TeachingTaskId = request.TeachingTaskId, Name = request.Name.Trim(), AttendanceDate = request.AttendanceDate, CheckInMethod = checkInMethod, CheckInToken = checkInToken, CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual ? null : now, CheckInEndsAt = checkInEndsAt, TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location ? request.TargetLatitude : null, TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location ? request.TargetLongitude : null, LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location ? request.LocationRadiusMeters : null, Notes = Normalize(request.Notes), Records = studentIds.Select(studentId => new AttendanceRecord { StudentId = studentId, Status = checkInMethod == AttendanceCheckInMethod.Manual ? AttendanceStatus.Present : AttendanceStatus.Absent }).ToList() }; db.AttendanceSheets.Add(sheet); await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { sheet.Id, sheet.CheckInMethod, sheet.CheckInStartsAt, sheet.CheckInEndsAt }); } [HttpGet("sheets/{id:guid}")] [Authorize(Roles = AttendanceRoles)] public async Task GetSheet(Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTaskId, x.Name, x.AttendanceDate, x.Status, x.CheckInMethod, x.CheckInStartsAt, x.CheckInEndsAt, x.TargetLatitude, x.TargetLongitude, x.LocationRadiusMeters, x.Notes, x.SubmittedAt, TaskNumber = x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, Records = x.Records .OrderBy(r => r.Student!.StudentNumber) .Select(r => new { r.StudentId, r.Student!.StudentNumber, r.Student.Name, ClassName = r.Student.AdministrativeClass!.Name, r.Status, r.Notes, r.CheckInAt, r.CheckedInMethod, r.CheckInAccuracyMeters, r.CheckInDistanceMeters }) }) .FirstOrDefaultAsync(cancellationToken); if (sheet is null) return NotFound(); if (!await AccessibleTasks().AsNoTracking() .AnyAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken)) return NotFound(); // Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite) var exemptStudentIds = await db.CourseExemptions .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Select(e => e.StudentId).ToHashSetAsync(cancellationToken); var deferredStudentIds = await db.DeferredExams .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Select(e => e.StudentId).ToHashSetAsync(cancellationToken); var canManage = await CanManageTaskAsync( sheet.TeachingTaskId, cancellationToken); var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; var now = DateTime.UtcNow; var attempts = await db.AttendanceCheckInAttempts.AsNoTracking() .Where(x => x.AttendanceSheetId == id) .Select(x => new { x.StudentId, x.CreatedAt, x.IsSuccessful, x.FailureCode, x.DeviceIdentifierHash, x.DevicePlatform, x.IpAddress, x.RiskFlags }) .ToListAsync(cancellationToken); var attemptsByStudent = attempts .GroupBy(x => x.StudentId) .ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList()); var deviceHashes = attempts .Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null) .Select(x => x.DeviceIdentifierHash!) .Distinct(StringComparer.Ordinal) .ToList(); var deviceReuseCounts = new Dictionary(StringComparer.Ordinal); if (deviceHashes.Count > 0) { var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking() .Where(x => x.IsSuccessful && x.CreatedAt >= now.AddHours(-24) && x.DeviceIdentifierHash != null) .WhereIn(deviceHashes, x => x.DeviceIdentifierHash!) .GroupBy(x => x.DeviceIdentifierHash!) .Select(x => new { DeviceIdentifierHash = x.Key, StudentCount = x.Select(a => a.StudentId).Distinct().Count() }) .ToListAsync(cancellationToken); deviceReuseCounts = reuseRows.ToDictionary( x => x.DeviceIdentifierHash, x => x.StudentCount, StringComparer.Ordinal); } var responseRecords = sheet.Records.Select(r => { var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? []; var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful); var riskFlags = studentAttempts .SelectMany(x => ParseRiskFlags(x.RiskFlags)) .ToHashSet(StringComparer.Ordinal); if (studentAttempts.Count(x => !x.IsSuccessful) >= 3) riskFlags.Add("RepeatedFailures"); if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6) riskFlags.Add("HighFrequency"); var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash ? deviceReuseCounts.GetValueOrDefault(deviceHash) : 0; if (sharedDeviceStudentCount > 1) riskFlags.Add("SharedDevice"); var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress ? attempts .Where(x => x.IsSuccessful && x.IpAddress == ipAddress) .Select(x => x.StudentId) .Distinct() .Count() : 0; return new { r.StudentId, r.StudentNumber, r.Name, r.ClassName, r.Status, r.Notes, r.CheckInAt, r.CheckedInMethod, r.CheckInAccuracyMeters, r.CheckInDistanceMeters, IsExempt = exemptStudentIds.Contains(r.StudentId), IsDeferred = deferredStudentIds.Contains(r.StudentId), CheckInAudit = latestSuccess is null ? null : new { latestSuccess.IpAddress, latestSuccess.DevicePlatform, DeviceCode = latestSuccess.DeviceIdentifierHash?[..8], SharedDeviceStudentCount = sharedDeviceStudentCount, SameIpStudentCount = sameIpStudentCount }, AttemptCount = studentAttempts.Count, FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful), RiskFlags = riskFlags.OrderBy(x => x).ToArray() }; }).ToList(); return Ok(new { Sheet = new { sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate, sheet.Status, sheet.CheckInMethod, sheet.CheckInStartsAt, sheet.CheckInEndsAt, sheet.TargetLatitude, sheet.TargetLongitude, sheet.LocationRadiusMeters, IsCheckInOpen = IsCheckInOpen( sheet.Status, sheet.CheckInMethod, sheet.CheckInStartsAt, sheet.CheckInEndsAt, now), CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null), sheet.Notes, sheet.SubmittedAt, sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName, Records = responseRecords, RiskSummary = new { RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0), SharedDeviceStudentCount = responseRecords.Count( x => x.RiskFlags.Contains("SharedDevice")), FrequentAttemptStudentCount = responseRecords.Count( x => x.RiskFlags.Contains("HighFrequency") || x.RiskFlags.Contains("RepeatedFailures")) } }, CanEdit = canEdit }); } [HttpPut("sheets/{id:guid}/records")] [Authorize(Roles = AttendanceRoles)] public async Task UpdateRecords( Guid id, AttendanceRecordsRequest request, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.Records) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); if (sheet.Status != AttendanceSheetStatus.Draft) return ConflictProblem("考勤表已提交,不能修改。"); var recordMap = sheet.Records.ToDictionary(r => r.StudentId); foreach (var item in request.Records) { if (recordMap.TryGetValue(item.StudentId, out var record)) { record.Status = item.Status; record.Notes = Normalize(item.Notes); } } await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("sheets/{id:guid}/import")] [Authorize(Roles = AttendanceRoles)] public async Task Import( Guid id, IFormFile file, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.Records) .ThenInclude(x => x.Student) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); if (sheet.Status != AttendanceSheetStatus.Draft) return ConflictProblem("考勤表已提交,不能导入。"); var rows = await ExcelWorkbookHelper.ReadAsync( file, new HashSet { "学号", "考勤状态" }, cancellationToken); var studentNumberMap = sheet.Records .ToDictionary(r => r.Student!.StudentNumber, r => r); var statusMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["出勤"] = AttendanceStatus.Present, ["缺勤"] = AttendanceStatus.Absent, ["迟到"] = AttendanceStatus.Late, ["请假"] = AttendanceStatus.Leave, ["免修"] = AttendanceStatus.Excused }; var updated = 0; foreach (var row in rows) { var studentNumber = row["学号"]; var statusText = row["考勤状态"]; if (string.IsNullOrEmpty(studentNumber)) continue; if (!studentNumberMap.TryGetValue(studentNumber, out var record)) continue; if (statusMap.TryGetValue(statusText, out var status)) { record.Status = status; updated++; } } await db.SaveChangesAsync(cancellationToken); return Ok(new { Updated = updated }); } [HttpGet("sheets/{id:guid}/export.xlsx")] [Authorize(Roles = AttendanceRoles)] public async Task Export(Guid id, CancellationToken cancellationToken) { var sheetData = await db.AttendanceSheets.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.TeachingTaskId, x.Name, x.AttendanceDate, TaskNumber = x.TeachingTask!.TaskNumber, CourseName = x.TeachingTask.Course!.Name, Records = x.Records .OrderBy(r => r.Student!.StudentNumber) .Select(r => new { r.Student!.StudentNumber, r.Student.Name, ClassName = r.Student.AdministrativeClass!.Name, r.Status, r.Notes }) .ToList() }) .FirstOrDefaultAsync(cancellationToken); if (sheetData is null) return NotFound(); if (!await AccessibleTasks().AsNoTracking() .AnyAsync(x => x.Id == sheetData.TeachingTaskId, cancellationToken)) return NotFound(); var statusLabels = new Dictionary { [AttendanceStatus.Present] = "出勤", [AttendanceStatus.Absent] = "缺勤", [AttendanceStatus.Late] = "迟到", [AttendanceStatus.Leave] = "请假", [AttendanceStatus.Excused] = "免修" }; var bytes = ExcelWorkbookHelper.Create( "考勤表", ["学号", "姓名", "班级", "考勤状态", "备注"], sheetData.Records.Select(r => new List { r.StudentNumber, r.Name, r.ClassName, statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes }).ToList>()); return File(bytes, ExcelWorkbookHelper.ContentType, $"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx"); } [HttpGet("tasks/{teachingTaskId:guid}/statistics")] [Authorize(Roles = AttendanceRoles)] public async Task GetTaskStatistics( Guid teachingTaskId, CancellationToken cancellationToken) { var statistics = await LoadTaskStatisticsAsync( teachingTaskId, cancellationToken); return statistics is null ? NotFound() : Ok(statistics); } [HttpGet("tasks/{teachingTaskId:guid}/statistics.xlsx")] [Authorize(Roles = AttendanceRoles)] public async Task ExportTaskStatistics( Guid teachingTaskId, CancellationToken cancellationToken) { var statistics = await LoadTaskStatisticsAsync( teachingTaskId, cancellationToken); if (statistics is null) return NotFound(); var bytes = CreateStatisticsWorkbook(statistics); return File( bytes, ExcelWorkbookHelper.ContentType, $"课程考勤统计-{statistics.Course.TaskNumber}.xlsx"); } [HttpPost("sheets/{id:guid}/submit")] [Authorize(Roles = AttendanceRoles)] public async Task Submit(Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); if (sheet.Status != AttendanceSheetStatus.Draft) return ConflictProblem("考勤表已提交。"); sheet.Status = AttendanceSheetStatus.Submitted; sheet.SubmittedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("sheets/{id:guid}/close-check-in")] [Authorize(Roles = AttendanceRoles)] public async Task CloseCheckIn( Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); if (sheet.Status != AttendanceSheetStatus.Draft) return ConflictProblem("考勤表已提交,签到活动已经结束。"); if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual) return ConflictProblem("普通点名没有在线签到活动。"); var now = DateTime.UtcNow; if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now) sheet.CheckInEndsAt = now; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpGet("sheets/{id:guid}/qr-challenge")] [Authorize(Roles = AttendanceRoles)] public async Task GetQrChallenge( Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode || string.IsNullOrWhiteSpace(sheet.CheckInToken)) return ConflictProblem("该考勤表不是扫码签到。"); var now = DateTime.UtcNow; if (!IsCheckInOpen( sheet.Status, sheet.CheckInMethod, sheet.CheckInStartsAt, sheet.CheckInEndsAt, now)) return ConflictProblem("签到尚未开始或已经结束。"); var challenge = AttendanceCheckInChallenge.Create( sheet.Id, sheet.CheckInToken, now); return Ok(challenge); } // ═══════════════ Student endpoints ═══════════════ [HttpGet("check-in-info")] [Authorize(Roles = SystemRoles.Student)] public async Task GetCheckInInfo( [FromQuery, MaxLength(64)] string token, CancellationToken cancellationToken) { var studentId = await GetCurrentStudentIdAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); if (string.IsNullOrWhiteSpace(token)) return NotFound(); if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId)) return NotFound(); var activity = await db.AttendanceRecords.AsNoTracking() .Where(x => x.StudentId == studentId.Value && x.AttendanceSheetId == sheetId) .Select(x => new { SheetId = x.AttendanceSheetId, SheetName = x.AttendanceSheet!.Name, x.AttendanceSheet.AttendanceDate, x.AttendanceSheet.Status, x.AttendanceSheet.CheckInMethod, x.AttendanceSheet.CheckInStartsAt, x.AttendanceSheet.CheckInEndsAt, x.AttendanceSheet.CheckInToken, CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, CourseName = x.AttendanceSheet.TeachingTask.Course.Name, TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, x.CheckInAt }) .FirstOrDefaultAsync(cancellationToken); if (activity is null) return NotFound(); var now = DateTime.UtcNow; if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode || !AttendanceCheckInChallenge.IsValid( token, activity.SheetId, activity.CheckInToken, now)) return NotFound(); return Ok(new { activity.SheetId, activity.SheetName, activity.AttendanceDate, activity.CheckInMethod, activity.CheckInStartsAt, activity.CheckInEndsAt, activity.CourseCode, activity.CourseName, activity.TaskNumber, activity.CheckInAt, IsOpen = IsCheckInOpen( activity.Status, activity.CheckInMethod, activity.CheckInStartsAt, activity.CheckInEndsAt, now) }); } [HttpGet("open-check-ins")] [Authorize(Roles = SystemRoles.Student)] public async Task GetOpenCheckIns( CancellationToken cancellationToken) { var studentId = await GetCurrentStudentIdAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var now = DateTime.UtcNow; return Ok(await db.AttendanceRecords.AsNoTracking() .Where(x => x.StudentId == studentId.Value && x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft && x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location && x.AttendanceSheet.CheckInStartsAt <= now && x.AttendanceSheet.CheckInEndsAt >= now) .OrderBy(x => x.AttendanceSheet!.CheckInEndsAt) .Select(x => new { SheetId = x.AttendanceSheetId, SheetName = x.AttendanceSheet!.Name, x.AttendanceSheet.AttendanceDate, x.AttendanceSheet.CheckInMethod, x.AttendanceSheet.CheckInStartsAt, x.AttendanceSheet.CheckInEndsAt, x.AttendanceSheet.LocationRadiusMeters, CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code, CourseName = x.AttendanceSheet.TeachingTask.Course.Name, TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber, x.CheckInAt }) .ToListAsync(cancellationToken)); } [HttpPost("check-in")] [Authorize(Roles = SystemRoles.Student)] public async Task CheckIn( AttendanceCheckInRequest request, CancellationToken cancellationToken) { var studentId = await GetCurrentStudentIdAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var source = db.AttendanceRecords .Include(x => x.AttendanceSheet) .Where(x => x.StudentId == studentId.Value); if (!string.IsNullOrWhiteSpace(request.Token)) { if (!AttendanceCheckInChallenge.TryReadSheetId( request.Token.Trim(), out var tokenSheetId)) return NotFound(); source = source.Where(x => x.AttendanceSheetId == tokenSheetId); } else if (request.AttendanceSheetId.HasValue) { source = source.Where(x => x.AttendanceSheetId == request.AttendanceSheetId.Value); } else { return ConflictProblem("缺少签到活动信息。"); } var record = await source.FirstOrDefaultAsync(cancellationToken); if (record?.AttendanceSheet is null) return NotFound(); var sheet = record.AttendanceSheet; var now = DateTime.UtcNow; async Task RejectAttemptAsync( string failureCode, string detail, double? distanceMeters = null) { await AddCheckInAttemptAsync( sheet, studentId.Value, request, false, failureCode, distanceMeters, now, cancellationToken); await db.SaveChangesAsync(cancellationToken); return ConflictProblem(detail); } if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode && !AttendanceCheckInChallenge.IsValid( request.Token, sheet.Id, sheet.CheckInToken, now)) return await RejectAttemptAsync( "InvalidQrChallenge", "签到二维码已失效,请重新扫描教师当前展示的二维码。"); if (!IsCheckInOpen( sheet.Status, sheet.CheckInMethod, sheet.CheckInStartsAt, sheet.CheckInEndsAt, now)) return await RejectAttemptAsync( "CheckInClosed", "签到尚未开始或已经结束。"); if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual) return await RejectAttemptAsync( "ManualSheet", "该考勤表不支持学生在线签到。"); if (record.CheckInAt.HasValue) { await AddCheckInAttemptAsync( sheet, studentId.Value, request, true, null, record.CheckInDistanceMeters, now, cancellationToken); await db.SaveChangesAsync(cancellationToken); return Ok(new { AlreadyCheckedIn = true, record.CheckInAt, record.CheckInDistanceMeters }); } double? distanceMeters = null; if (sheet.CheckInMethod == AttendanceCheckInMethod.Location) { if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180 || request.Latitude is null || request.Longitude is null) return await RejectAttemptAsync( "InvalidLocation", "未获取到有效的当前位置。"); if (sheet.TargetLatitude is null || sheet.TargetLongitude is null || sheet.LocationRadiusMeters is null) return await RejectAttemptAsync( "LocationNotConfigured", "签到活动没有配置有效的位置范围。"); var maximumAllowedAccuracyMeters = Math.Min( 100d, sheet.LocationRadiusMeters.Value); if (request.AccuracyMeters is null || !double.IsFinite(request.AccuracyMeters.Value) || request.AccuracyMeters <= 0 || request.AccuracyMeters > maximumAllowedAccuracyMeters) { return await RejectAttemptAsync( "InsufficientAccuracy", $"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。"); } distanceMeters = CalculateDistanceMeters( (double)sheet.TargetLatitude.Value, (double)sheet.TargetLongitude.Value, (double)request.Latitude.Value, (double)request.Longitude.Value); if (distanceMeters > sheet.LocationRadiusMeters.Value) { return await RejectAttemptAsync( "OutsideGeofence", $"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。", distanceMeters); } } record.Status = AttendanceStatus.Present; record.CheckInAt = now; record.CheckedInMethod = sheet.CheckInMethod; record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location ? request.Latitude : null; record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location ? request.Longitude : null; record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location ? request.AccuracyMeters : null; record.CheckInDistanceMeters = distanceMeters; await AddCheckInAttemptAsync( sheet, studentId.Value, request, true, null, distanceMeters, now, cancellationToken); await db.SaveChangesAsync(cancellationToken); return Ok(new { AlreadyCheckedIn = false, record.CheckInAt, record.CheckInDistanceMeters }); } [HttpGet("my-records")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyRecords( Guid? academicTermId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var source = db.AttendanceRecords.AsNoTracking() .Where(r => r.StudentId == studentId.Value && r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted); if (academicTermId.HasValue) source = source.Where(r => r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) .Select(r => new { r.AttendanceSheetId, r.AttendanceSheet!.TeachingTaskId, SheetName = r.AttendanceSheet!.Name, r.AttendanceSheet.AttendanceDate, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, CourseName = r.AttendanceSheet.TeachingTask.Course.Name, TeacherNames = r.AttendanceSheet.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name), r.Status, r.Notes, r.AppealStatus, r.AppealReason, r.AppealSubmittedAt, r.AppealReviewComment, r.AppealReviewedAt }) .ToListAsync(cancellationToken)); } [HttpPost("records/appeal")] [Authorize(Roles = SystemRoles.Student)] public async Task SubmitAppeal( AttendanceAppealRequest request, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var record = await db.AttendanceRecords .FirstOrDefaultAsync(r => r.AttendanceSheetId == request.AttendanceSheetId && r.StudentId == studentId.Value, cancellationToken); if (record is null) return NotFound(); if (record.AppealStatus == AttendanceAppealStatus.Pending) return ConflictProblem("已有申诉正在处理中。"); if (record.AppealStatus == AttendanceAppealStatus.Approved) return ConflictProblem("该考勤记录申诉已通过。"); record.AppealStatus = AttendanceAppealStatus.Pending; record.AppealReason = request.Reason.Trim(); record.AppealSubmittedAt = DateTime.UtcNow; record.AppealReviewComment = null; record.AppealReviewedAt = null; await db.SaveChangesAsync(cancellationToken); // Notify the course teacher var teacherUserIds = await db.TeachingTaskTeachers .Where(x => x.TeachingTaskId == record.AttendanceSheet!.TeachingTaskId) .Select(x => x.Teacher!.UserId) .Where(id => id != null) .Select(id => id!.Value) .ToListAsync(cancellationToken); if (teacherUserIds.Count > 0) { var studentName = await db.Students .Where(s => s.Id == studentId.Value) .Select(s => s.Name) .FirstOrDefaultAsync(cancellationToken); var courseName = record.AttendanceSheet!.TeachingTask?.Course?.Name ?? ""; await NotificationService.SendToUserIdsAsync(db, teacherUserIds, "考勤申诉待处理", $"学生 {studentName} 对《{courseName}》考勤记录提出申诉。", "/teacher-attendance", cancellationToken, NotificationCategory.Attendance); } return NoContent(); } // ═══════════════ Counselor endpoints ═══════════════ [HttpGet("counselor-records")] [Authorize(Roles = SystemRoles.Counselor)] public async Task GetCounselorRecords( Guid? academicTermId, Guid? classId, bool? withAppeal, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var classIds = await db.AdministrativeClasses .Where(c => c.CounselorUserId == userId) .Select(c => c.Id) .ToListAsync(cancellationToken); if (classIds.Count == 0) return ConflictProblem("当前账号未关联任何班级。"); if (classId.HasValue && !classIds.Contains(classId.Value)) return ConflictProblem("您不是该班级的辅导员。"); var targetClassIds = classId.HasValue ? [classId.Value] : classIds; var source = db.AttendanceRecords.AsNoTracking() .Where(r => r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted) .WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId); if (academicTermId.HasValue) source = source.Where(r => r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); if (withAppeal == true) source = source.Where(r => r.AppealStatus == AttendanceAppealStatus.Pending); return Ok(await source .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) .Select(r => new { r.AttendanceSheetId, SheetName = r.AttendanceSheet!.Name, r.AttendanceSheet.AttendanceDate, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, CourseName = r.AttendanceSheet.TeachingTask.Course.Name, StudentNumber = r.Student!.StudentNumber, StudentName = r.Student.Name, ClassName = r.Student.AdministrativeClass!.Name, r.Status, r.Notes, r.AppealStatus, r.AppealReason, r.AppealSubmittedAt, r.AppealReviewComment }) .ToListAsync(cancellationToken)); } [HttpPost("appeals/{attendanceSheetId:guid}/{studentId:guid}/review")] [Authorize(Roles = AttendanceRoles)] public async Task ReviewAppeal( Guid attendanceSheetId, Guid studentId, AttendanceAppealReviewRequest request, CancellationToken cancellationToken) { var record = await db.AttendanceRecords .Include(r => r.AttendanceSheet) .ThenInclude(s => s!.TeachingTask) .ThenInclude(t => t!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(r => r.AttendanceSheetId == attendanceSheetId && r.StudentId == studentId, cancellationToken); if (record is null) return NotFound(); if (!CanManageSheet(record.AttendanceSheet!)) return Forbid(); if (record.AppealStatus != AttendanceAppealStatus.Pending) return ConflictProblem("该申诉不在待处理状态。"); record.AppealStatus = request.Approve ? AttendanceAppealStatus.Approved : AttendanceAppealStatus.Rejected; record.AppealReviewComment = request.Comment?.Trim(); record.AppealReviewedAt = DateTime.UtcNow; // Update record status on approved appeal if (request.Approve) record.Status = AttendanceStatus.Excused; await db.SaveChangesAsync(cancellationToken); // Notify student var studentUserId = await db.Students .Where(s => s.Id == studentId) .Select(s => s.UserId) .FirstOrDefaultAsync(cancellationToken); if (studentUserId.HasValue) { var result = request.Approve ? "已通过" : "已驳回"; await NotificationService.SendAsync(db, studentUserId.Value, $"考勤申诉{result}", request.Comment is not null ? $"您的考勤申诉{result}。意见:{request.Comment}" : $"您的考勤申诉{result}。", null, cancellationToken, NotificationCategory.Attendance); } return NoContent(); } [HttpDelete("sheets/{id:guid}")] [Authorize(Roles = AttendanceRoles)] public async Task Delete(Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanManageSheet(sheet)) return Forbid(); db.AttendanceSheets.Remove(sheet); await db.SaveChangesAsync(cancellationToken); return NoContent(); } private IQueryable AccessibleTasks() { var source = db.TeachingTasks.AsQueryable(); var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); if (scope.IsInRole(SystemRoles.Counselor)) { var collegeIds = db.AdministrativeClasses .Where(c => c.CounselorUserId == scope.UserId) .Select(c => c.Major!.CollegeId) .Distinct(); return source.Where(x => collegeIds.Contains(x.Course!.CollegeId)); } if (scope.IsInRole(SystemRoles.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); return source.Where(_ => false); } private bool CanManageSheet(AttendanceSheet sheet) => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || sheet.TeachingTask!.Teachers.Any(x => x.Teacher?.UserId == currentUserDataScope.Current.UserId); private async Task CanManageTaskAsync( Guid teachingTaskId, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; if (scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin)) return true; return await db.TeachingTaskTeachers.AsNoTracking() .AnyAsync( x => x.TeachingTaskId == teachingTaskId && x.Teacher!.UserId == scope.UserId, cancellationToken); } private async Task GetCurrentStudentIdAsync( CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return await db.Students.AsNoTracking() .Where(x => x.UserId == userId) .Select(x => (Guid?)x.Id) .FirstOrDefaultAsync(cancellationToken); } private async Task AddCheckInAttemptAsync( AttendanceSheet sheet, Guid studentId, AttendanceCheckInRequest request, bool isSuccessful, string? failureCode, double? distanceMeters, DateTime now, CancellationToken cancellationToken) { var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId); var riskFlags = new HashSet(StringComparer.Ordinal); if (deviceIdentifierHash is null) { riskFlags.Add("MissingDeviceId"); } else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync( x => x.IsSuccessful && x.StudentId != studentId && x.DeviceIdentifierHash == deviceIdentifierHash && x.CreatedAt >= now.AddHours(-24), cancellationToken)) { riskFlags.Add("SharedDevice"); } var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking() .CountAsync( x => x.StudentId == studentId && x.CreatedAt >= now.AddMinutes(-2), cancellationToken); if (recentAttemptCount >= 5) riskFlags.Add("HighFrequency"); if (!isSuccessful) { var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking() .CountAsync( x => x.StudentId == studentId && !x.IsSuccessful && x.CreatedAt >= now.AddMinutes(-5), cancellationToken); if (recentFailureCount >= 2) riskFlags.Add("RepeatedFailures"); } var context = ControllerContext.HttpContext; db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt { AttendanceSheetId = sheet.Id, StudentId = studentId, CheckInMethod = sheet.CheckInMethod, IsSuccessful = isSuccessful, FailureCode = failureCode, DeviceIdentifierHash = deviceIdentifierHash, DevicePlatform = Limit(Normalize(request.DevicePlatform), 32), IpAddress = Limit( context?.Connection.RemoteIpAddress?.ToString(), 64), UserAgent = Limit( context?.Request.Headers.UserAgent.ToString(), 500), RiskFlags = riskFlags.Count == 0 ? null : string.Join(',', riskFlags.OrderBy(x => x)), Latitude = request.Latitude, Longitude = request.Longitude, AccuracyMeters = request.AccuracyMeters, DistanceMeters = distanceMeters, CreatedAt = now, UpdatedAt = now }); } private static string? HashDeviceIdentifier(string? deviceId) { var normalized = Normalize(deviceId)?.ToLowerInvariant(); return normalized is null ? null : Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(normalized))); } private static IEnumerable ParseRiskFlags(string? value) => string.IsNullOrWhiteSpace(value) ? [] : value.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private static string? Limit(string? value, int maximumLength) => value is null || value.Length <= maximumLength ? value : value[..maximumLength]; private static bool IsCheckInOpen( AttendanceSheetStatus status, AttendanceCheckInMethod method, DateTime? startsAt, DateTime? endsAt, DateTime now) => status == AttendanceSheetStatus.Draft && method != AttendanceCheckInMethod.Manual && startsAt.HasValue && endsAt.HasValue && startsAt.Value <= now && endsAt.Value >= now; internal static double CalculateDistanceMeters( double latitude1, double longitude1, double latitude2, double longitude2) { const double earthRadiusMeters = 6_371_000; var latitudeDelta = DegreesToRadians(latitude2 - latitude1); var longitudeDelta = DegreesToRadians(longitude2 - longitude1); var startLatitude = DegreesToRadians(latitude1); var endLatitude = DegreesToRadians(latitude2); var haversine = Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) + Math.Cos(startLatitude) * Math.Cos(endLatitude) * Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2); return earthRadiusMeters * 2 * Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine)); } private static double DegreesToRadians(double degrees) => degrees * Math.PI / 180; private async Task LoadTaskStatisticsAsync( Guid teachingTaskId, CancellationToken cancellationToken) { var course = await AccessibleTasks().AsNoTracking() .Where(x => x.Id == teachingTaskId) .Select(x => new AttendanceStatisticsCourse( x.Id, x.TaskNumber, x.Name, x.Course!.Code, x.Course.Name, x.AcademicTerm!.Name)) .FirstOrDefaultAsync(cancellationToken); if (course is null) return null; var enrolledStudents = await TeachingTaskRosterQuery .ForTask(db, teachingTaskId) .AsNoTracking() .Select(x => new AttendanceStatisticsStudentIdentity( x.Id, x.StudentNumber, x.Name, x.AdministrativeClass!.Name)) .ToListAsync(cancellationToken); var submittedSheets = await db.AttendanceSheets.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId && x.Status == AttendanceSheetStatus.Submitted) .OrderBy(x => x.AttendanceDate) .ThenBy(x => x.Name) .Select(x => new AttendanceStatisticsSheet( x.Id, x.Name, x.AttendanceDate)) .ToListAsync(cancellationToken); var records = await db.AttendanceRecords.AsNoTracking() .Where(x => x.AttendanceSheet!.TeachingTaskId == teachingTaskId && x.AttendanceSheet.Status == AttendanceSheetStatus.Submitted) .Select(x => new AttendanceStatisticsRecord( x.AttendanceSheetId, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Student.AdministrativeClass!.Name, x.Status)) .ToListAsync(cancellationToken); var identities = enrolledStudents .Concat(records.Select(x => new AttendanceStatisticsStudentIdentity( x.StudentId, x.StudentNumber, x.StudentName, x.ClassName))) .GroupBy(x => x.StudentId) .Select(x => x.First()) .ToList(); var recordsByStudent = records .GroupBy(x => x.StudentId) .ToDictionary(x => x.Key, x => x.ToList()); var students = identities.Select(identity => { var studentRecords = recordsByStudent.GetValueOrDefault(identity.StudentId) ?? []; var present = studentRecords.Count(x => x.Status == AttendanceStatus.Present); var absent = studentRecords.Count(x => x.Status == AttendanceStatus.Absent); var late = studentRecords.Count(x => x.Status == AttendanceStatus.Late); var leave = studentRecords.Count(x => x.Status == AttendanceStatus.Leave); var excused = studentRecords.Count(x => x.Status == AttendanceStatus.Excused); var required = studentRecords.Count - excused; return new AttendanceStudentStatistics( identity.StudentId, identity.StudentNumber, identity.StudentName, identity.ClassName, studentRecords.Count, required, present, absent, late, leave, excused, CalculateAttendanceRate(present, late, required)); }) .OrderBy(x => x.AttendanceRate ?? decimal.MaxValue) .ThenBy(x => x.StudentNumber) .ToList(); var recordsBySheet = records .GroupBy(x => x.AttendanceSheetId) .ToDictionary(x => x.Key, x => x.ToList()); var sessions = submittedSheets.Select(sheet => { var sheetRecords = recordsBySheet.GetValueOrDefault(sheet.Id) ?? []; var present = sheetRecords.Count(x => x.Status == AttendanceStatus.Present); var absent = sheetRecords.Count(x => x.Status == AttendanceStatus.Absent); var late = sheetRecords.Count(x => x.Status == AttendanceStatus.Late); var leave = sheetRecords.Count(x => x.Status == AttendanceStatus.Leave); var excused = sheetRecords.Count(x => x.Status == AttendanceStatus.Excused); var required = sheetRecords.Count - excused; return new AttendanceSessionStatistics( sheet.Id, sheet.Name, sheet.AttendanceDate, sheetRecords.Count, required, present, absent, late, leave, excused, CalculateAttendanceRate(present, late, required)); }).ToList(); var totalPresent = records.Count(x => x.Status == AttendanceStatus.Present); var totalAbsent = records.Count(x => x.Status == AttendanceStatus.Absent); var totalLate = records.Count(x => x.Status == AttendanceStatus.Late); var totalLeave = records.Count(x => x.Status == AttendanceStatus.Leave); var totalExcused = records.Count(x => x.Status == AttendanceStatus.Excused); var totalRequired = records.Count - totalExcused; var summary = new AttendanceStatisticsSummary( students.Count, sessions.Count, records.Count, totalRequired, CalculateAttendanceRate(totalPresent, totalLate, totalRequired), students.Count(x => x.RequiredCount > 0 && x.PresentCount == x.RequiredCount), students.Count(x => x.AbsentCount > 0 || x.LateCount > 0)); var distribution = new[] { new AttendanceStatusStatistics( AttendanceStatus.Present, "出勤", totalPresent), new AttendanceStatusStatistics( AttendanceStatus.Absent, "缺勤", totalAbsent), new AttendanceStatusStatistics( AttendanceStatus.Late, "迟到", totalLate), new AttendanceStatusStatistics( AttendanceStatus.Leave, "请假", totalLeave), new AttendanceStatusStatistics( AttendanceStatus.Excused, "免修", totalExcused) }; return new AttendanceCourseStatistics( course, summary, distribution, sessions, students); } private static decimal? CalculateAttendanceRate( int present, int late, int required) { if (required <= 0) return null; return Math.Round( (present + late) * 100m / required, 1, MidpointRounding.AwayFromZero); } private static byte[] CreateStatisticsWorkbook( AttendanceCourseStatistics statistics) { using var workbook = new XLWorkbook(); var overview = workbook.Worksheets.Add("课程概览"); overview.Cell("A1").Value = "课程考勤统计"; overview.Cell("A1").Style.Font.Bold = true; overview.Cell("A1").Style.Font.FontSize = 16; overview.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D"); var overviewRows = new (string Label, object? Value)[] { ("课程", $"{statistics.Course.CourseCode} {statistics.Course.CourseName}"), ("教学任务", statistics.Course.TaskNumber), ("学期", statistics.Course.TermName), ("学生人数", statistics.Summary.StudentCount), ("已提交点名", statistics.Summary.SubmittedSheetCount), ("考勤记录", statistics.Summary.RecordCount), ("课程总体出勤率", statistics.Summary.OverallAttendanceRate), ("全勤人数", statistics.Summary.PerfectAttendanceCount), ("存在缺勤或迟到人数", statistics.Summary.AbnormalStudentCount), ("统计口径", "仅统计已提交点名;出勤和迟到计为到课,免修不计入应到次数。") }; for (var index = 0; index < overviewRows.Length; index++) { var row = index + 3; overview.Cell(row, 1).Value = overviewRows[index].Label; overview.Cell(row, 1).Style.Font.Bold = true; if (overviewRows[index].Value is decimal rate) { overview.Cell(row, 2).Value = rate / 100m; overview.Cell(row, 2).Style.NumberFormat.Format = "0.0%"; } else { overview.Cell(row, 2).Value = overviewRows[index].Value?.ToString() ?? "暂无"; } } overview.Column(1).Width = 22; overview.Column(2).Width = 58; overview.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; var studentSheet = workbook.Worksheets.Add("学生出勤明细"); var studentHeaders = new[] { "学号", "姓名", "行政班", "统计次数", "应到次数", "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" }; WriteHeader(studentSheet, studentHeaders); for (var index = 0; index < statistics.Students.Count; index++) { var student = statistics.Students[index]; var row = index + 2; studentSheet.Cell(row, 1).Value = student.StudentNumber; studentSheet.Cell(row, 2).Value = student.StudentName; studentSheet.Cell(row, 3).Value = student.ClassName; studentSheet.Cell(row, 4).Value = student.TotalCount; studentSheet.Cell(row, 5).Value = student.RequiredCount; studentSheet.Cell(row, 6).Value = student.PresentCount; studentSheet.Cell(row, 7).Value = student.AbsentCount; studentSheet.Cell(row, 8).Value = student.LateCount; studentSheet.Cell(row, 9).Value = student.LeaveCount; studentSheet.Cell(row, 10).Value = student.ExcusedCount; if (student.AttendanceRate.HasValue) { studentSheet.Cell(row, 11).Value = student.AttendanceRate.Value / 100m; studentSheet.Cell(row, 11).Style.NumberFormat.Format = "0.0%"; } else { studentSheet.Cell(row, 11).Value = "暂无"; } } FinishDataSheet(studentSheet, studentHeaders.Length); var sessionSheet = workbook.Worksheets.Add("历次点名趋势"); var sessionHeaders = new[] { "日期", "点名名称", "记录数", "应到人数", "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" }; WriteHeader(sessionSheet, sessionHeaders); for (var index = 0; index < statistics.Sessions.Count; index++) { var session = statistics.Sessions[index]; var row = index + 2; sessionSheet.Cell(row, 1).Value = session.AttendanceDate; sessionSheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd"; sessionSheet.Cell(row, 2).Value = session.Name; sessionSheet.Cell(row, 3).Value = session.TotalCount; sessionSheet.Cell(row, 4).Value = session.RequiredCount; sessionSheet.Cell(row, 5).Value = session.PresentCount; sessionSheet.Cell(row, 6).Value = session.AbsentCount; sessionSheet.Cell(row, 7).Value = session.LateCount; sessionSheet.Cell(row, 8).Value = session.LeaveCount; sessionSheet.Cell(row, 9).Value = session.ExcusedCount; if (session.AttendanceRate.HasValue) { sessionSheet.Cell(row, 10).Value = session.AttendanceRate.Value / 100m; sessionSheet.Cell(row, 10).Style.NumberFormat.Format = "0.0%"; } else { sessionSheet.Cell(row, 10).Value = "暂无"; } } FinishDataSheet(sessionSheet, sessionHeaders.Length); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } private static void WriteHeader( IXLWorksheet sheet, IReadOnlyList headers) { for (var index = 0; index < headers.Count; index++) { var cell = sheet.Cell(1, index + 1); cell.Value = headers[index]; cell.Style.Font.Bold = true; cell.Style.Font.FontColor = XLColor.White; cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D"); cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; } } private static void FinishDataSheet(IXLWorksheet sheet, int columnCount) { sheet.SheetView.FreezeRows(1); sheet.RangeUsed()?.SetAutoFilter(); sheet.Columns(1, columnCount).AdjustToContents(10, 28); sheet.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; } private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成考勤操作", Detail = detail, Status = StatusCodes.Status409Conflict }); } public sealed record AttendanceSheetRequest( Guid TeachingTaskId, [MaxLength(120)] string Name, DateTime AttendanceDate, [MaxLength(500)] string? Notes, AttendanceCheckInMethod? CheckInMethod, [Range(1, 180)] int? CheckInDurationMinutes, [Range(-90, 90)] decimal? TargetLatitude, [Range(-180, 180)] decimal? TargetLongitude, [Range(20, 1000)] int? LocationRadiusMeters); public sealed record AttendanceRecordsRequest( IReadOnlyCollection Records); public sealed record AttendanceRecordRequest( Guid StudentId, AttendanceStatus Status, [MaxLength(300)] string? Notes); public sealed record AttendanceAppealRequest( Guid AttendanceSheetId, [Required, MaxLength(500)] string Reason); public sealed record AttendanceAppealReviewRequest( bool Approve, [MaxLength(300)] string? Comment); public sealed record AttendanceCheckInRequest( Guid? AttendanceSheetId, [MaxLength(64)] string? Token, [Range(-90, 90)] decimal? Latitude, [Range(-180, 180)] decimal? Longitude, [Range(0, 5000)] double? AccuracyMeters, [MaxLength(128)] string? DeviceId = null, [MaxLength(32)] string? DevicePlatform = null); public sealed record AttendanceCourseStatistics( AttendanceStatisticsCourse Course, AttendanceStatisticsSummary Summary, IReadOnlyList StatusDistribution, IReadOnlyList Sessions, IReadOnlyList Students); public sealed record AttendanceStatisticsCourse( Guid Id, string TaskNumber, string TaskName, string CourseCode, string CourseName, string TermName); public sealed record AttendanceStatisticsSummary( int StudentCount, int SubmittedSheetCount, int RecordCount, int RequiredCount, decimal? OverallAttendanceRate, int PerfectAttendanceCount, int AbnormalStudentCount); public sealed record AttendanceStatusStatistics( AttendanceStatus Status, string Label, int Count); public sealed record AttendanceSessionStatistics( Guid Id, string Name, DateTime AttendanceDate, int TotalCount, int RequiredCount, int PresentCount, int AbsentCount, int LateCount, int LeaveCount, int ExcusedCount, decimal? AttendanceRate); public sealed record AttendanceStudentStatistics( Guid StudentId, string StudentNumber, string StudentName, string ClassName, int TotalCount, int RequiredCount, int PresentCount, int AbsentCount, int LateCount, int LeaveCount, int ExcusedCount, decimal? AttendanceRate); internal sealed record AttendanceStatisticsStudentIdentity( Guid StudentId, string StudentNumber, string StudentName, string ClassName); internal sealed record AttendanceStatisticsSheet( Guid Id, string Name, DateTime AttendanceDate); internal sealed record AttendanceStatisticsRecord( Guid AttendanceSheetId, Guid StudentId, string StudentNumber, string StudentName, string ClassName, AttendanceStatus Status);