update
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInToken,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt
|
||||
});
|
||||
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInToken,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.TargetLatitude,
|
||||
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
|
||||
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<string, int>(StringComparer.Ordinal);
|
||||
if (deviceHashes.Count > 0)
|
||||
{
|
||||
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsSuccessful &&
|
||||
x.CreatedAt >= now.AddHours(-24) &&
|
||||
x.DeviceIdentifierHash != null &&
|
||||
deviceHashes.Contains(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
|
||||
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
sheet.TargetLatitude,
|
||||
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
|
||||
sheet.TaskName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
Records = sheet.Records.Select(r => new
|
||||
Records = responseRecords,
|
||||
RiskSummary = 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)
|
||||
})
|
||||
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
|
||||
});
|
||||
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> 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")]
|
||||
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
|
||||
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.AttendanceSheet!.CheckInToken == token.Trim())
|
||||
x.AttendanceSheetId == sheetId)
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
|
||||
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,
|
||||
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
|
||||
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,
|
||||
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
|
||||
.Where(x => x.StudentId == studentId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
var token = request.Token.Trim();
|
||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||
request.Token.Trim(),
|
||||
out var tokenSheetId))
|
||||
return NotFound();
|
||||
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||
}
|
||||
else if (request.AttendanceSheetId.HasValue)
|
||||
{
|
||||
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
|
||||
if (record?.AttendanceSheet is null) return NotFound();
|
||||
var sheet = record.AttendanceSheet;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
async Task<ActionResult> 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,
|
||||
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||
|
||||
double? distanceMeters = null;
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||
!string.Equals(
|
||||
sheet.CheckInToken,
|
||||
request.Token.Trim(),
|
||||
StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
}
|
||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
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 ConflictProblem("未获取到有效的当前位置。");
|
||||
return await RejectAttemptAsync(
|
||||
"InvalidLocation",
|
||||
"未获取到有效的当前位置。");
|
||||
if (sheet.TargetLatitude is null ||
|
||||
sheet.TargetLongitude is null ||
|
||||
sheet.LocationRadiusMeters is null)
|
||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||
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,
|
||||
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
|
||||
(double)request.Longitude.Value);
|
||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||
return await RejectAttemptAsync(
|
||||
"OutsideGeofence",
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||
distanceMeters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
|
||||
? request.AccuracyMeters
|
||||
: null;
|
||||
record.CheckInDistanceMeters = distanceMeters;
|
||||
await AddCheckInAttemptAsync(
|
||||
sheet,
|
||||
studentId.Value,
|
||||
request,
|
||||
true,
|
||||
null,
|
||||
distanceMeters,
|
||||
now,
|
||||
cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
|
||||
.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<string>(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<string> 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,
|
||||
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
||||
[MaxLength(64)] string? Token,
|
||||
[Range(-90, 90)] decimal? Latitude,
|
||||
[Range(-180, 180)] decimal? Longitude,
|
||||
[Range(0, 5000)] double? AccuracyMeters);
|
||||
[Range(0, 5000)] double? AccuracyMeters,
|
||||
[MaxLength(128)] string? DeviceId = null,
|
||||
[MaxLength(32)] string? DevicePlatform = null);
|
||||
|
||||
public sealed record AttendanceCourseStatistics(
|
||||
AttendanceStatisticsCourse Course,
|
||||
|
||||
Reference in New Issue
Block a user