update
This commit is contained in:
1 parent
a61dacff1a
commit
2dae303cf1
17 files changed
+6794
-88
No files matched your search
@@ -47,6 +47,26 @@ dotnet run --project src/Jiaowu.Api
|
||||
|
||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||
|
||||
## Capacitor Android App
|
||||
|
||||
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||
Android 工程前先构建并同步原生插件:
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
npm ci
|
||||
npm run build:capacitor
|
||||
npm run cap:sync
|
||||
npm run cap:open:android
|
||||
```
|
||||
|
||||
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||
|
||||
## MySQL 8.4 生产部署
|
||||
|
||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user