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,
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class AttendanceRecord
|
||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
||||
public DateTime? AppealReviewedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||
{
|
||||
public Guid AttendanceSheetId { get; set; }
|
||||
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||
public bool IsSuccessful { get; set; }
|
||||
public string? FailureCode { get; set; }
|
||||
public string? DeviceIdentifierHash { get; set; }
|
||||
public string? DevicePlatform { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? RiskFlags { get; set; }
|
||||
public decimal? Latitude { get; set; }
|
||||
public decimal? Longitude { get; set; }
|
||||
public double? AccuracyMeters { get; set; }
|
||||
public double? DistanceMeters { get; set; }
|
||||
}
|
||||
|
||||
public enum AttendanceSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -57,6 +57,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
Set<AttendanceCheckInAttempt>();
|
||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||
Set<ExamArrangementJob>();
|
||||
@@ -698,6 +700,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||
{
|
||||
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||
entity.HasOne(x => x.AttendanceSheet)
|
||||
.WithMany(x => x.CheckInAttempts)
|
||||
.HasForeignKey(x => x.AttendanceSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExamPlan>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -66,6 +66,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260727_35_academic_planning_prerequisites";
|
||||
private const string ExamRoomMixingMigration =
|
||||
"20260727_36_exam_room_mixing";
|
||||
private const string AttendanceCheckInAuditMigration =
|
||||
"20260728_37_attendance_check_in_audit";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -324,6 +326,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
AttendanceCheckInMigration,
|
||||
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
||||
cancellationToken);
|
||||
var attendanceCheckInAttemptsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AttendanceCheckInAuditMigration,
|
||||
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
|
||||
cancellationToken);
|
||||
|
||||
var approvalTablesExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
||||
@@ -1929,6 +1943,47 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] AttendanceCheckInAuditStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
|
||||
"AttendanceSheetId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"CheckInMethod" INTEGER NOT NULL,
|
||||
"IsSuccessful" INTEGER NOT NULL,
|
||||
"FailureCode" TEXT NULL,
|
||||
"DeviceIdentifierHash" TEXT NULL,
|
||||
"DevicePlatform" TEXT NULL,
|
||||
"IpAddress" TEXT NULL,
|
||||
"UserAgent" TEXT NULL,
|
||||
"RiskFlags" TEXT NULL,
|
||||
"Latitude" TEXT NULL,
|
||||
"Longitude" TEXT NULL,
|
||||
"AccuracyMeters" REAL NULL,
|
||||
"DistanceMeters" REAL NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
|
||||
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
|
||||
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
|
||||
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] ApprovalTableStatements =
|
||||
[
|
||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||
|
||||
+5417
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AttendanceCheckInAudit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AttendanceCheckInAttempts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CheckInMethod = table.Column<int>(type: "int", nullable: false),
|
||||
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
|
||||
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
|
||||
DistanceMeters = table.Column<double>(type: "double", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AttendanceCheckInAttempts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
|
||||
column: x => x.AttendanceSheetId,
|
||||
principalTable: "AttendanceSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
columns: new[] { "IpAddress", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceCheckInAttempts_StudentId",
|
||||
table: "AttendanceCheckInAttempts",
|
||||
column: "StudentId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AttendanceCheckInAttempts");
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -137,6 +137,81 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("AdministrativeClasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<double?>("AccuracyMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<Guid>("AttendanceSheetId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("CheckInMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("DeviceIdentifierHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("DevicePlatform")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<double?>("DistanceMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<string>("FailureCode")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<bool>("IsSuccessful")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal?>("Latitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("Longitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<string>("RiskFlags")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<Guid>("StudentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StudentId");
|
||||
|
||||
b.HasIndex("DeviceIdentifierHash", "CreatedAt");
|
||||
|
||||
b.HasIndex("IpAddress", "CreatedAt");
|
||||
|
||||
b.HasIndex("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||
|
||||
b.ToTable("AttendanceCheckInAttempts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("AttendanceSheetId")
|
||||
@@ -3938,6 +4013,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Major");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
|
||||
.WithMany("CheckInAttempts")
|
||||
.HasForeignKey("AttendanceSheetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AttendanceSheet");
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet")
|
||||
@@ -5163,6 +5257,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b =>
|
||||
{
|
||||
b.Navigation("CheckInAttempts");
|
||||
|
||||
b.Navigation("Records");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||
|
||||
public static class AttendanceCheckInChallenge
|
||||
{
|
||||
public const int LifetimeSeconds = 20;
|
||||
public const int RefreshSeconds = 10;
|
||||
private const int MaximumClockSkewSeconds = 2;
|
||||
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
public static AttendanceCheckInChallengeResult Create(
|
||||
Guid attendanceSheetId,
|
||||
string secret,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var issuedAt = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
|
||||
var payload = CreatePayload(
|
||||
attendanceSheetId,
|
||||
ToBase36(issuedAt.ToUnixTimeSeconds()));
|
||||
var signature = CreateSignature(payload, secret);
|
||||
return new AttendanceCheckInChallengeResult(
|
||||
$"{payload}.{signature}",
|
||||
issuedAt.UtcDateTime,
|
||||
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
|
||||
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
|
||||
}
|
||||
|
||||
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
|
||||
{
|
||||
attendanceSheetId = Guid.Empty;
|
||||
if (!TryParse(token, out var sheetIdText, out _, out _))
|
||||
return false;
|
||||
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
|
||||
}
|
||||
|
||||
public static bool IsValid(
|
||||
string? token,
|
||||
Guid attendanceSheetId,
|
||||
string? secret,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret) ||
|
||||
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
|
||||
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
|
||||
tokenSheetId != attendanceSheetId ||
|
||||
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var nowUnixSeconds = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
|
||||
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
|
||||
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
|
||||
return false;
|
||||
|
||||
byte[] providedSignature;
|
||||
try
|
||||
{
|
||||
providedSignature = WebEncoders.Base64UrlDecode(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = WebEncoders.Base64UrlDecode(
|
||||
CreateSignature(
|
||||
CreatePayload(attendanceSheetId, issuedAtText),
|
||||
secret));
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
providedSignature,
|
||||
expectedSignature);
|
||||
}
|
||||
|
||||
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
|
||||
$"{attendanceSheetId:N}.{issuedAtText}";
|
||||
|
||||
private static string CreateSignature(string payload, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return WebEncoders.Base64UrlEncode(digest[..16]);
|
||||
}
|
||||
|
||||
private static bool TryParse(
|
||||
string? token,
|
||||
out string sheetIdText,
|
||||
out string issuedAtText,
|
||||
out string signature)
|
||||
{
|
||||
sheetIdText = string.Empty;
|
||||
issuedAtText = string.Empty;
|
||||
signature = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
|
||||
return false;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3 ||
|
||||
parts[0].Length != 32 ||
|
||||
parts[1].Length is < 1 or > 12 ||
|
||||
parts[2].Length != 22)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sheetIdText = parts[0];
|
||||
issuedAtText = parts[1];
|
||||
signature = parts[2];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToBase36(long value)
|
||||
{
|
||||
if (value == 0) return "0";
|
||||
Span<char> buffer = stackalloc char[13];
|
||||
var index = buffer.Length;
|
||||
while (value > 0)
|
||||
{
|
||||
buffer[--index] = Base36Digits[(int)(value % 36)];
|
||||
value /= 36;
|
||||
}
|
||||
return new string(buffer[index..]);
|
||||
}
|
||||
|
||||
private static bool TryParseBase36(string value, out long result)
|
||||
{
|
||||
result = 0;
|
||||
foreach (var character in value)
|
||||
{
|
||||
var digit = Base36Digits.IndexOf(
|
||||
char.ToLower(character, CultureInfo.InvariantCulture));
|
||||
if (digit < 0) return false;
|
||||
try
|
||||
{
|
||||
result = checked(result * 36 + digit);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AttendanceCheckInChallengeResult(
|
||||
string Token,
|
||||
DateTime IssuedAt,
|
||||
DateTime ExpiresAt,
|
||||
DateTime RefreshAt);
|
||||
Reference in New Issue
Block a user