diff --git a/src/HikvisionAttendanceManager.App/Services/HikvisionIsapiClient.cs b/src/HikvisionAttendanceManager.App/Services/HikvisionIsapiClient.cs index 4f82ed2..2694324 100644 --- a/src/HikvisionAttendanceManager.App/Services/HikvisionIsapiClient.cs +++ b/src/HikvisionAttendanceManager.App/Services/HikvisionIsapiClient.cs @@ -4,6 +4,8 @@ using System.Net.Http; using System.IO; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; using HikvisionAttendanceManager.App.Models; namespace HikvisionAttendanceManager.App.Services; @@ -11,64 +13,204 @@ namespace HikvisionAttendanceManager.App.Services; /// Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService. public sealed class HikvisionIsapiClient { - private const string FaceLibraryType = "blackFD"; - private const string FaceLibraryId = "1"; + private const string FaceDataRecordPath = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json"; + private const string FaceLibDiscoveryPath = "/ISAPI/Intelligent/FDLib?format=json"; + // Legacy template download still uses a default library when FDSearch is called elsewhere. + private const string TemplateFaceLibraryType = "blackFD"; + private const string TemplateFaceLibraryId = "1"; + private const int TestTimeoutSeconds = 10; + private const int ResponsePreviewLength = 320; + private const int FaceVerificationRetryDelayMs = 300; public async Task TestConnectionAsync(Device device, CancellationToken cancellationToken) { if (!TryGetCredentials(device, out var username, out var password, out var credentialError)) - return IsapiTestResult.Failed(credentialError); + return IsapiTestResult.CredentialsMissing(credentialError); using var client = CreateTestClient(device, username, password); - try + var logLines = new List(); + + foreach (var path in new[] { "/ISAPI/System/deviceInfo", "/ISAPI/System/deviceInfo?format=json" }) { - using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var requestUri = new Uri(client.BaseAddress!, path).AbsoluteUri; + logLines.Add($"request={requestUri}"); - if (response.StatusCode == HttpStatusCode.Unauthorized) - return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed)."); + try + { + using var response = await client.GetAsync(path, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "(none)"; + logLines.Add($"status={(int)response.StatusCode} {response.StatusCode}"); + logLines.Add($"contentType={contentType}"); + logLines.Add($"bodyPreview={SafeBodyPreview(body)}"); - if (!response.IsSuccessStatusCode) - return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode})."); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + return IsapiTestResult.AuthenticationFailed("Authentication failed (invalid Hikvision credentials).", logLines); - using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); - var deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name; - var model = FindStringProperty(document.RootElement, "model", "deviceType", "Model"); - var firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion"); - return IsapiTestResult.Succeeded(deviceName, model, firmware); - } - catch (HttpRequestException) - { - return IsapiTestResult.Failed("Device unreachable. Check the IP address and network connection."); + if (response.StatusCode == HttpStatusCode.NotFound && path.Contains("format=json", StringComparison.Ordinal)) + continue; + + if (!response.IsSuccessStatusCode) + return IsapiTestResult.Offline($"ISAPI request failed (HTTP {(int)response.StatusCode}).", logLines); + + if (TryParseDeviceInfo(body, contentType, device, out var deviceName, out var model, out var firmware, out var parseNote)) + { + if (!string.IsNullOrWhiteSpace(parseNote)) + return IsapiTestResult.ApiResponseError(parseNote, deviceName, model, firmware, logLines); + + return IsapiTestResult.Online(deviceName, model, firmware, logLines); + } + + return IsapiTestResult.ApiResponseError( + "Device responded but returned an unexpected ISAPI payload.", + device.Name, + null, + null, + logLines); + } + catch (HttpRequestException ex) + { + logLines.Add($"networkError={ex.Message}"); + if (path.Contains("format=json", StringComparison.Ordinal)) + return IsapiTestResult.Offline("Device unreachable. Check the IP address and network connection.", logLines); + } + catch (OperationCanceledException) + { + logLines.Add("timeout=true"); + return IsapiTestResult.Timeout("Device did not respond within the connection timeout.", logLines); + } + catch (JsonException ex) + { + logLines.Add($"jsonError={ex.Message}"); + return IsapiTestResult.ApiResponseError("Device returned JSON that could not be parsed.", device.Name, null, null, logLines); + } + catch (Exception ex) when (ex is System.Xml.XmlException or InvalidOperationException) + { + logLines.Add($"xmlError={ex.Message}"); + return IsapiTestResult.ApiResponseError("Device returned XML that could not be parsed.", device.Name, null, null, logLines); + } } + + return IsapiTestResult.Offline("Device unreachable. Check the IP address and network connection.", logLines); } - private static bool TryGetCredentials(Device device, out string username, out string password, out string error) + private static bool TryParseDeviceInfo( + string body, + string contentType, + Device device, + out string? deviceName, + out string? model, + out string? firmware, + out string? parseNote) { - username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? ""; - password = !string.IsNullOrWhiteSpace(device.ProtectedPassword) - ? PasswordProtector.Unprotect(device.ProtectedPassword) - : Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? ""; - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + deviceName = null; + model = null; + firmware = null; + parseNote = null; + + if (string.IsNullOrWhiteSpace(body)) { - error = "Hikvision credentials are not configured for this device."; - return false; + deviceName = device.Name; + parseNote = "Device responded with an empty body; connectivity and authentication succeeded."; + return true; } - error = ""; - return true; + + if (LooksLikeXml(body, contentType)) + { + try + { + var document = XDocument.Parse(body); + deviceName = FindXmlValue(document, "deviceName", "DeviceName") ?? device.Name; + model = FindXmlValue(document, "model", "deviceType", "Model"); + firmware = FindXmlValue(document, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion"); + return true; + } + catch (Exception ex) + { + parseNote = $"XML parse warning: {ex.Message}"; + deviceName = device.Name; + return true; + } + } + + if (LooksLikeJson(body, contentType)) + { + try + { + using var document = JsonDocument.Parse(body); + deviceName = FindStringProperty(document.RootElement, "deviceName", "DeviceName") ?? device.Name; + model = FindStringProperty(document.RootElement, "model", "deviceType", "Model"); + firmware = FindStringProperty(document.RootElement, "firmwareVersion", "firmwareReleasedDate", "FirmwareVersion"); + return true; + } + catch (JsonException ex) + { + parseNote = $"JSON parse warning: {ex.Message}"; + deviceName = device.Name; + return true; + } + } + + if (body.Contains("deviceName", StringComparison.OrdinalIgnoreCase) || + body.Contains("DeviceInfo", StringComparison.OrdinalIgnoreCase)) + { + deviceName = device.Name; + parseNote = "Device returned a recognizable ISAPI payload with an unsupported format."; + return true; + } + + return false; } + private static bool LooksLikeXml(string body, string contentType) => + contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) || + body.TrimStart().StartsWith('<'); + + private static bool LooksLikeJson(string body, string contentType) => + contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + body.TrimStart().StartsWith('{') || + body.TrimStart().StartsWith('['); + + private static string? FindXmlValue(XDocument document, params string[] localNames) + { + foreach (var name in localNames) + { + var value = document.Descendants() + .FirstOrDefault(element => element.Name.LocalName.Equals(name, StringComparison.OrdinalIgnoreCase)) + ?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; + } + + private static string SafeBodyPreview(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return "(empty)"; + + var compact = body.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return compact.Length <= ResponsePreviewLength + ? compact + : compact[..ResponsePreviewLength] + "..."; + } + + private static bool TryGetCredentials(Device device, out string username, out string password, out string error) => + HikvisionCredentialsFactory.TryGetForDevice(device, out username, out password, out error); + private static HttpClient CreateTestClient(Device device, string username, string password) => new(new SocketsHttpHandler { Credentials = new NetworkCredential(username, password), PreAuthenticate = false, - ConnectTimeout = TimeSpan.FromSeconds(10), + ConnectTimeout = TimeSpan.FromSeconds(TestTimeoutSeconds), PooledConnectionLifetime = TimeSpan.Zero }) { BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), - Timeout = TimeSpan.FromSeconds(10) + Timeout = TimeSpan.FromSeconds(TestTimeoutSeconds) }; private static HttpClient CreateClient(Device device) @@ -86,6 +228,7 @@ public sealed class HikvisionIsapiClient public async Task UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(UserExistsAsync), $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Search"); using var client = CreateClient(device); var json = JsonSerializer.Serialize(new { @@ -99,11 +242,15 @@ public sealed class HikvisionIsapiClient }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); - return response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase); + var exists = response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase); + AppLogger.TraceExit("ISAPI", nameof(UserExistsAsync), $"employee={employeeNo} exists={exists} http={(int)response.StatusCode}"); + return exists; } public async Task CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(CreateUserAsync), + $"device={device.IpAddress} employee={employee.SerialNumber} path=/ISAPI/AccessControl/UserInfo/Record"); using var client = CreateClient(device); var payload = JsonSerializer.Serialize(new { @@ -118,7 +265,10 @@ public sealed class HikvisionIsapiClient } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); - return await ToResultAsync(response, cancellationToken); + var result = await ToResultAsync(response, cancellationToken); + AppLogger.TraceExit("ISAPI", nameof(CreateUserAsync), + $"employee={employee.SerialNumber} success={result.Success} reason={result.Reason}"); + return result; } /// Lightweight user count via UserInfo Search totalMatches (maxResults=1). @@ -148,6 +298,8 @@ public sealed class HikvisionIsapiClient public async Task CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(CreateUserAsync), + $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Record"); using var client = CreateClient(device); var payload = JsonSerializer.Serialize(new { @@ -162,64 +314,185 @@ public sealed class HikvisionIsapiClient } }); using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken); - return await ToResultAsync(response, cancellationToken); + var result = await ToResultAsync(response, cancellationToken); + AppLogger.TraceExit("ISAPI", nameof(CreateUserAsync), + $"employee={employeeNo} success={result.Success} reason={result.Reason}"); + return result; } public async Task UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken) { - if (!IsJpeg(jpeg)) return ApiResult.Failed("Face processing failed: photo is not a valid JPEG."); + AppLogger.TraceEnter("ISAPI", nameof(UploadFaceAsync), + $"device={device.IpAddress} employee={employeeNo} bytes={jpeg.Length} path={FaceDataRecordPath}"); + if (!IsJpeg(jpeg)) + { + AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=not-jpeg"); + return ApiResult.Failed("Face processing failed: photo is not a valid JPEG."); + } + + if (!FaceImageNormalizer.TryNormalizeForEnrollment(jpeg, out var normalizedJpeg, out var normalizeNote)) + { + AppLogger.Warning($"[ISAPI] Face normalization failed employee={employeeNo} note={normalizeNote}"); + AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=normalize-failed"); + return ApiResult.Failed($"Face processing failed: {normalizeNote}"); + } + + AppLogger.Info($"[ISAPI] Face normalized employee={employeeNo} {normalizeNote}"); + jpeg = normalizedJpeg; + using var client = CreateClient(device); - var boundary = "---------------" + Guid.NewGuid().ToString("N"); - var metadata = $$"""{"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}"}"""; - var body = BuildFaceMultipart(boundary, metadata, jpeg); - using var content = new ByteArrayContent(body); - content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}"); - using var response = await client.PostAsync("/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", content, cancellationToken); - return await ToResultAsync(response, cancellationToken); + var libraries = await DiscoverFaceLibrariesAsync(client, cancellationToken).ConfigureAwait(false); + if (libraries.Count == 0) + { + AppLogger.Warning("[ISAPI] FDLib discovery returned no libraries."); + AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=no-fdlib"); + return ApiResult.Failed("Face library discovery failed. The device did not return any FDLib entries."); + } + + AppLogger.Info($"[ISAPI] FDLib discovery count={libraries.Count} libraries={string.Join(", ", libraries.Select(l => $"{l.FaceLibType}:{l.Fdid}"))}"); + + ApiResult? lastFailure = null; + foreach (var library in OrderFaceLibraries(libraries)) + { + cancellationToken.ThrowIfCancellationRequested(); + AppLogger.Info($"[ISAPI] Face upload attempt employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid}"); + var attempt = await TryUploadFaceToLibraryAsync(client, employeeNo, jpeg, library, cancellationToken).ConfigureAwait(false); + if (attempt.Success) + { + AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), + $"employee={employeeNo} success=true faceLibType={library.FaceLibType} FDID={library.Fdid}"); + return attempt; + } + + lastFailure = attempt; + AppLogger.Warning($"[ISAPI] Face upload failed employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid} reason={attempt.Reason}"); + } + + AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false"); + return lastFailure ?? ApiResult.Failed("Face upload failed on all discovered libraries."); } public async Task VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken) { - using var client = CreateClient(device); - var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; - using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) return ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); - return body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase) - ? ApiResult.Succeeded() - : ApiResult.Failed("Face enrollment could not be verified by this device."); + AppLogger.TraceEnter("ISAPI", nameof(VerifyFaceAsync), + $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Search"); + for (var attempt = 0; attempt < 2; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (attempt > 0) + await Task.Delay(FaceVerificationRetryDelayMs, cancellationToken).ConfigureAwait(false); + + var faceCount = await TryGetUserFaceCountAsync(device, employeeNo, cancellationToken).ConfigureAwait(false); + AppLogger.Info($"[ISAPI] Face verification attempt={attempt + 1} employee={employeeNo} numOfFace={faceCount?.ToString() ?? "unknown"}"); + if (faceCount is >= 1) + { + AppLogger.TraceExit("ISAPI", nameof(VerifyFaceAsync), $"employee={employeeNo} success=true numOfFace={faceCount}"); + return ApiResult.Succeeded(); + } + } + + AppLogger.TraceExit("ISAPI", nameof(VerifyFaceAsync), $"employee={employeeNo} success=false numOfFace=0"); + return ApiResult.Failed("Face enrollment could not be verified (numOfFace < 1)."); } - public async Task DeleteUsersAsync(Device device, IReadOnlyList employeeNumbers, CancellationToken cancellationToken) + public async Task TryGetUserFaceCountAsync(Device device, string employeeNo, CancellationToken cancellationToken) { - if (employeeNumbers.Count == 0) return ApiResult.Succeeded(); using var client = CreateClient(device); + var json = JsonSerializer.Serialize(new + { + UserInfoSearchCond = new + { + searchID = "1", + searchResultPosition = 0, + maxResults = 10, + EmployeeNoList = new[] { new { employeeNo } } + } + }); + using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + + using var document = JsonDocument.Parse(body); + var users = FindUsers(document.RootElement); + var match = users.FirstOrDefault(user => string.Equals(user.EmployeeNo, employeeNo, StringComparison.OrdinalIgnoreCase)); + return match?.FaceCount; + } + + public async Task> DeleteUsersAsync(Device device, IReadOnlyList employeeNumbers, CancellationToken cancellationToken) + { + AppLogger.TraceEnter("ISAPI", nameof(DeleteUsersAsync), + $"device={device.IpAddress} count={employeeNumbers.Count} employees={string.Join(",", employeeNumbers)}"); + if (employeeNumbers.Count == 0) return []; + using var client = CreateClient(device); + var results = new List(); const int batchSize = 30; for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize) { cancellationToken.ThrowIfCancellationRequested(); - var slice = employeeNumbers.Skip(offset).Take(batchSize).Select(e => new { employeeNo = e }).ToArray(); - var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = slice } }); - using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode || !IsSuccessfulIsapiBody(body)) + var slice = employeeNumbers.Skip(offset).Take(batchSize).ToList(); + var batchResult = await TryDeleteEmployeeBatchAsync(client, slice, cancellationToken).ConfigureAwait(false); + if (batchResult.Success) { - var fallback = JsonSerializer.Serialize(new - { - UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = slice } - }); - using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put, - "/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallback, cancellationToken); - var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken); - if (!fallbackResponse.IsSuccessStatusCode || !IsSuccessfulIsapiBody(fallbackBody)) - return ApiResult.Failed(ExtractReason(fallbackBody, ExtractReason(body, $"HTTP {(int)fallbackResponse.StatusCode}"))); + results.AddRange(slice.Select(employeeNo => new UserDeleteResult(employeeNo, ApiResult.Succeeded()))); + continue; + } + + foreach (var employeeNo in slice) + { + var singleResult = await TryDeleteEmployeeBatchAsync(client, [employeeNo], cancellationToken).ConfigureAwait(false); + results.Add(new UserDeleteResult(employeeNo, singleResult)); } } - return ApiResult.Succeeded(); + + AppLogger.TraceExit("ISAPI", nameof(DeleteUsersAsync), + $"success={results.Count(r => r.Result.Success)} failed={results.Count(r => !r.Result.Success)}"); + return results; + } + + private static async Task TryDeleteEmployeeBatchAsync(HttpClient client, IReadOnlyList employeeNumbers, CancellationToken cancellationToken) + { + var employeeLabel = employeeNumbers.Count == 1 ? employeeNumbers[0] : $"batch[{employeeNumbers.Count}]"; + AppLogger.TraceEnter("ISAPI", nameof(TryDeleteEmployeeBatchAsync), + $"employees={employeeLabel} path=/ISAPI/AccessControl/UserInfo/Delete"); + var list = employeeNumbers.Select(e => new { employeeNo = e }).ToArray(); + var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = list } }); + using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var httpStatus = (int)response.StatusCode; + if (IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _)) + { + AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync), + $"employees={employeeLabel} success=true attempt=primary http={httpStatus}"); + return ApiResult.Succeeded(); + } + + AppLogger.Info($"[ISAPI] TryDeleteEmployeeBatchAsync primary failed employees={employeeLabel} http={httpStatus}; trying fallback"); + var fallbackPayload = JsonSerializer.Serialize(new + { + UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = list } + }); + using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put, + "/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallbackPayload, cancellationToken); + var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var fallbackHttpStatus = (int)fallbackResponse.StatusCode; + if (IsConfirmedIsapiSuccess(fallbackBody, fallbackHttpStatus, out _, out _, out _)) + { + AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync), + $"employees={employeeLabel} success=true attempt=fallback http={fallbackHttpStatus}"); + return ApiResult.Succeeded(); + } + + var failure = ApiResult.Failed(DescribeIsapiFailure(body, httpStatus, fallbackBody, fallbackHttpStatus)); + AppLogger.TraceExit("ISAPI", nameof(TryDeleteEmployeeBatchAsync), + $"employees={employeeLabel} success=false reason={failure.Reason}"); + return failure; } public async Task> GetUsersAsync(Device device, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(GetUsersAsync), + $"device={device.IpAddress} path=/ISAPI/AccessControl/UserInfo/Search"); using var client = CreateClient(device); var users = new List(); const int pageSize = 100; @@ -233,11 +506,14 @@ public sealed class HikvisionIsapiClient users.AddRange(page); if (page.Count < pageSize) break; } + AppLogger.TraceExit("ISAPI", nameof(GetUsersAsync), $"count={users.Count}"); return users; } public async Task> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(FetchAcsEventsAsync), + $"device={device.IpAddress} from={fromLocal:yyyy-MM-dd HH:mm} to={toLocal:yyyy-MM-dd HH:mm} path=/ISAPI/AccessControl/AcsEvent"); using var client = CreateClient(device); const uint major = 0; const uint minor = 0; @@ -284,21 +560,32 @@ public sealed class HikvisionIsapiClient if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10); } + AppLogger.TraceExit("ISAPI", nameof(FetchAcsEventsAsync), $"count={punches.Count}"); return punches; } public async Task DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken) { + AppLogger.TraceEnter("ISAPI", nameof(DownloadFaceTemplateAsync), + $"device={device.IpAddress} employee={employeeNo} path=/ISAPI/Intelligent/FDLib/FDSearch"); using var client = CreateClient(device); - var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; + var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{TemplateFaceLibraryType}}","FDID":"{{TemplateFaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}"""; using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) + { + AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync), + $"employee={employeeNo} success=false http={(int)response.StatusCode}"); return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode})."); + } var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl"); if (string.IsNullOrWhiteSpace(faceUrl)) + { + AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync), + $"employee={employeeNo} success=false reason=no-face-url"); return ApiResultWithBytes.Failed("No face template URL returned by device."); + } var path = faceUrl; if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute)) @@ -306,12 +593,19 @@ public sealed class HikvisionIsapiClient using var imageResponse = await client.GetAsync(path, cancellationToken); if (!imageResponse.IsSuccessStatusCode) + { + AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync), + $"employee={employeeNo} success=false imageHttp={(int)imageResponse.StatusCode}"); return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode})."); + } var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken); - return IsJpeg(bytes) + var downloadResult = IsJpeg(bytes) ? ApiResultWithBytes.Succeeded(bytes) : ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template."); + AppLogger.TraceExit("ISAPI", nameof(DownloadFaceTemplateAsync), + $"employee={employeeNo} success={downloadResult.Success} bytes={bytes.Length}"); + return downloadResult; } public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; @@ -481,45 +775,378 @@ public sealed class HikvisionIsapiClient private static async Task ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken); - return response.IsSuccessStatusCode && IsSuccessfulIsapiBody(body) - ? ApiResult.Succeeded() - : ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}")); + var httpStatus = (int)response.StatusCode; + if (response.IsSuccessStatusCode && + (string.IsNullOrWhiteSpace(body) || IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _))) + return ApiResult.Succeeded(); + + return ApiResult.Failed(DescribeIsapiFailure(body, httpStatus)); } - private static bool IsSuccessfulIsapiBody(string body) => - string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase); + /// HTTP 2xx + Hikvision statusCode 1 + statusString OK or subStatusCode ok. + private static bool IsConfirmedIsapiSuccess(string? body, int httpStatus, out int statusCode, out string statusString, out string subStatusCode) + { + statusCode = 0; + statusString = ""; + subStatusCode = ""; + + if (httpStatus is < 200 or >= 300) + return false; + + if (!TryParseIsapiResponseFields(body, out statusCode, out statusString, out subStatusCode)) + return false; + + if (string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase)) + return true; + + if (statusCode != 1) + return false; + + return string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseIsapiResponseFields(string? body, out int statusCode, out string statusString, out string subStatusCode) + { + statusCode = 0; + statusString = ""; + subStatusCode = ""; + if (string.IsNullOrWhiteSpace(body)) + return false; + + if (body.TrimStart().StartsWith('{') || body.TrimStart().StartsWith('[')) + { + try + { + using var document = JsonDocument.Parse(body); + var parsedCode = FindIntProperty(document.RootElement, "statusCode", "responseStatusCode"); + if (parsedCode >= 0) + statusCode = parsedCode; + statusString = FindStringProperty(document.RootElement, "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString"); + subStatusCode = FindStringProperty(document.RootElement, "subStatusCode"); + } + catch (JsonException) + { + // fall through to regex + } + } + + if (statusCode == 0) + { + var match = Regex.Match(body, "\"statusCode\"\\s*:\\s*\"?(?-?\\d+)\"?", RegexOptions.IgnoreCase); + if (match.Success) + int.TryParse(match.Groups["v"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out statusCode); + } + + if (string.IsNullOrEmpty(statusString)) + { + var match = Regex.Match(body, "\"statusString\"\\s*:\\s*\"(?[^\"]*)\"", RegexOptions.IgnoreCase); + if (match.Success) + statusString = match.Groups["v"].Value; + } + + if (string.IsNullOrEmpty(subStatusCode)) + { + var match = Regex.Match(body, "\"subStatusCode\"\\s*:\\s*\"(?[^\"]*)\"", RegexOptions.IgnoreCase); + if (match.Success) + subStatusCode = match.Groups["v"].Value; + } + + return statusCode != 0 + || !string.IsNullOrWhiteSpace(statusString) + || !string.IsNullOrWhiteSpace(subStatusCode); + } + + private static string DescribeIsapiFailure(string? body, int httpStatus, string? fallbackBody = null, int? fallbackHttpStatus = null) + { + if (httpStatus is < 200 or >= 300) + return $"HTTP {httpStatus}"; + + if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode)) + return DescribeParsedIsapiFailure(httpStatus, statusCode, statusString, subStatusCode); + + if (fallbackBody is not null && fallbackHttpStatus is int fallbackStatus) + { + if (fallbackStatus is < 200 or >= 300) + return $"HTTP {fallbackStatus}"; + + if (TryParseIsapiResponseFields(fallbackBody, out statusCode, out statusString, out subStatusCode)) + return DescribeParsedIsapiFailure(fallbackStatus, statusCode, statusString, subStatusCode); + } + + return string.IsNullOrWhiteSpace(body) ? "Device rejected the ISAPI request." : TrimReason(body); + } + + private static string DescribeParsedIsapiFailure(int httpStatus, int statusCode, string statusString, string subStatusCode) + { + if (httpStatus is < 200 or >= 300) + return $"HTTP {httpStatus}"; + + var sub = subStatusCode.Trim(); + if (sub.Length > 0) + { + if (sub.Contains("notExist", StringComparison.OrdinalIgnoreCase) || + sub.Contains("notFound", StringComparison.OrdinalIgnoreCase) || + sub.Equals("invalidEmployeeNo", StringComparison.OrdinalIgnoreCase) || + sub.Equals("employeeNoNotExist", StringComparison.OrdinalIgnoreCase)) + return "Employee was not found on the device"; + + if (!string.Equals(sub, "ok", StringComparison.OrdinalIgnoreCase)) + return sub; + } + + if (!string.IsNullOrWhiteSpace(statusString) && + !string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase)) + return statusString.Trim(); + + if (statusCode is not (0 or 1)) + return $"Hikvision statusCode={statusCode}"; + + return "Device rejected the ISAPI request."; + } private static string ExtractReason(string body, string fallback) { - foreach (var key in new[] { "subStatusCode", "statusString", "errorMsg" }) + if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode)) { - var marker = $"\"{key}\""; - var index = body.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (index >= 0) return body.Substring(index, Math.Min(160, body.Length - index)).Replace("\r", " ").Replace("\n", " "); + var reason = DescribeParsedIsapiFailure(200, statusCode, statusString, subStatusCode); + if (!string.Equals(reason, "Device rejected the ISAPI request.", StringComparison.Ordinal)) + return reason; } + return fallback; } - private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg) + private static string TrimReason(string body) + { + var compact = body.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return compact.Length <= 160 ? compact : compact[..160] + "..."; + } + + private static async Task> DiscoverFaceLibrariesAsync(HttpClient client, CancellationToken cancellationToken) + { + AppLogger.TraceEnter("ISAPI", nameof(DiscoverFaceLibrariesAsync), $"path={FaceLibDiscoveryPath}"); + using var response = await client.GetAsync(FaceLibDiscoveryPath, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "(none)"; + AppLogger.Info($"[ISAPI] FDLib discovery http={(int)response.StatusCode} contentType={contentType} bodyPreview={SafeBodyPreview(body)}"); + + if (!response.IsSuccessStatusCode) + { + AppLogger.TraceExit("ISAPI", nameof(DiscoverFaceLibrariesAsync), "success=false"); + return []; + } + + var libraries = ParseFaceLibraryCandidates(body); + AppLogger.TraceExit("ISAPI", nameof(DiscoverFaceLibrariesAsync), $"success=true count={libraries.Count}"); + return libraries; + } + + private static IReadOnlyList OrderFaceLibraries(IReadOnlyList libraries) => + libraries + .OrderBy(library => library.FaceLibType.Equals("blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(library => library.FaceLibType, StringComparer.OrdinalIgnoreCase) + .ThenBy(library => library.Fdid, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static async Task TryUploadFaceToLibraryAsync( + HttpClient client, + string employeeNo, + byte[] jpeg, + FaceLibraryInfo library, + CancellationToken cancellationToken) + { + var metadata = JsonSerializer.Serialize(new + { + faceLibType = library.FaceLibType, + FDID = library.Fdid, + FPID = employeeNo + }); + var boundary = "---------------" + DateTime.Now.Ticks.ToString("x", CultureInfo.InvariantCulture); + var bodyBytes = BuildHikvisionFaceMultipartBody(boundary, metadata, jpeg); + using var content = new ByteArrayContent(bodyBytes); + content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}"); + using var request = new HttpRequestMessage(HttpMethod.Post, FaceDataRecordPath) { Content = content }; + request.Headers.TryAddWithoutValidation("Accept", "application/json, text/html, application/xhtml+xml"); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var httpStatus = (int)response.StatusCode; + var responseContentType = response.Content.Headers.ContentType?.MediaType ?? "(none)"; + TryParseIsapiResponseFields(responseBody, out var statusCode, out var statusString, out var subStatusCode); + AppLogger.Info( + $"[ISAPI] FaceDataRecord employee={employeeNo} faceLibType={library.FaceLibType} FDID={library.Fdid} " + + $"http={httpStatus} contentType={responseContentType} statusCode={statusCode} statusString={statusString} subStatusCode={subStatusCode} " + + $"bodyPreview={SafeBodyPreview(responseBody)}"); + + if (!response.IsSuccessStatusCode) + return ApiResult.Failed(DescribeFaceUploadFailure(httpStatus, responseBody)); + + if (!IsLikelyIsapiSuccess(responseBody, httpStatus)) + return ApiResult.Failed(DescribeFaceUploadFailure(httpStatus, responseBody)); + + return ApiResult.Succeeded(); + } + + private static List ParseFaceLibraryCandidates(string body) + { + var candidates = new List(); + if (string.IsNullOrWhiteSpace(body)) + return candidates; + + try + { + using var document = JsonDocument.Parse(body); + CollectFaceLibraries(document.RootElement, candidates); + } + catch (JsonException) + { + // fall through to regex below + } + + if (candidates.Count == 0) + { + foreach (Match match in Regex.Matches(body, "\"faceLibType\"\\s*:\\s*\"(?[^\"]+)\".*?\"FDID\"\\s*:\\s*\"?(?[^\",}]+)\"?", RegexOptions.IgnoreCase | RegexOptions.Singleline)) + { + var faceLibType = match.Groups["type"].Value.Trim(); + var fdid = match.Groups["fdid"].Value.Trim(); + if (!string.IsNullOrWhiteSpace(faceLibType) && !string.IsNullOrWhiteSpace(fdid)) + AddFaceLibraryCandidate(candidates, faceLibType, fdid); + } + } + + return candidates; + } + + private static void CollectFaceLibraries(JsonElement element, List candidates) + { + if (element.ValueKind == JsonValueKind.Object) + { + var fdid = FindStringProperty(element, "FDID", "fdId", "fdid"); + var faceLibType = FindStringProperty(element, "faceLibType", "faceLib", "libType"); + if (!string.IsNullOrWhiteSpace(fdid) && !string.IsNullOrWhiteSpace(faceLibType)) + AddFaceLibraryCandidate(candidates, faceLibType, fdid); + + foreach (var property in element.EnumerateObject()) + CollectFaceLibraries(property.Value, candidates); + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + CollectFaceLibraries(item, candidates); + } + } + + private static void AddFaceLibraryCandidate(List candidates, string faceLibType, string fdid) + { + var normalizedType = faceLibType.Trim(); + var normalizedFdid = fdid.Trim(); + if (candidates.Any(candidate => + candidate.FaceLibType.Equals(normalizedType, StringComparison.OrdinalIgnoreCase) && + candidate.Fdid.Equals(normalizedFdid, StringComparison.OrdinalIgnoreCase))) + return; + + candidates.Add(new FaceLibraryInfo(normalizedType, normalizedFdid)); + } + + private static bool IsLikelyIsapiSuccess(string? body, int httpStatus) + { + if (httpStatus is < 200 or >= 300) + return false; + if (string.IsNullOrWhiteSpace(body)) + return true; + if (!TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode)) + return true; + + if (statusCode == 1) + return true; + + return string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase) + || string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase); + } + + private static string DescribeFaceUploadFailure(int httpStatus, string? body) + { + if (TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode)) + { + if (!string.IsNullOrWhiteSpace(statusString) && + !string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase)) + { + return !string.IsNullOrWhiteSpace(subStatusCode) + ? $"{statusString.Trim()} ({subStatusCode.Trim()})" + : statusString.Trim(); + } + + if (!string.IsNullOrWhiteSpace(subStatusCode) && + !string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase)) + return subStatusCode.Trim(); + + if (statusCode is not (0 or 1)) + return $"Hikvision statusCode={statusCode}"; + } + + if (httpStatus is < 200 or >= 300) + return $"HTTP {httpStatus}"; + + return "Face upload rejected by device."; + } + + /// Hand-built multipart body matching Hikvision FaceDataRecord requirements. + private static byte[] BuildHikvisionFaceMultipartBody(string boundary, string faceDataRecordJson, byte[] jpegBytes) { using var stream = new MemoryStream(); - void Write(string text) { var bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes); } - var metadataBytes = Encoding.UTF8.GetBytes(metadata); - Write($"--{boundary}\r\nContent-Disposition: form-data; name=\"FaceDataRecord\";\r\nContent-Type: application/json\r\nContent-Length: {metadataBytes.Length}\r\n\r\n"); - stream.Write(metadataBytes); - Write($"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"FaceImage\";\r\nContent-Type: image/jpeg\r\nContent-Length: {jpeg.Length}\r\n\r\n"); - stream.Write(jpeg); - Write($"\r\n--{boundary}--\r\n"); + void WriteAscii(string text) + { + var bytes = Encoding.ASCII.GetBytes(text); + stream.Write(bytes, 0, bytes.Length); + } + + var jsonBytes = Encoding.UTF8.GetBytes(faceDataRecordJson ?? ""); + + WriteAscii("--" + boundary + "\r\n"); + WriteAscii("Content-Disposition: form-data; name=\"FaceDataRecord\";\r\n"); + WriteAscii("Content-Type: application/json\r\n"); + WriteAscii("Content-Length: " + jsonBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n"); + stream.Write(jsonBytes, 0, jsonBytes.Length); + + WriteAscii("\r\n--" + boundary + "\r\n"); + WriteAscii("Content-Disposition: form-data; name=\"FaceImage\";\r\n"); + WriteAscii("Content-Type: image/jpeg\r\n"); + WriteAscii("Content-Length: " + jpegBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n"); + stream.Write(jpegBytes, 0, jpegBytes.Length); + WriteAscii("\r\n--" + boundary + "--\r\n"); + return stream.ToArray(); } + + private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg) => + BuildHikvisionFaceMultipartBody(boundary, metadata, jpeg); } -public sealed record IsapiTestResult(bool Success, string Reason, string? DeviceName, string? Model, string? Firmware) +public sealed record IsapiTestResult( + ConnectionTestOutcome Outcome, + string Reason, + string? DeviceName, + string? Model, + string? Firmware, + IReadOnlyList LogLines) { - public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) => - new(true, "", deviceName, model, firmware); + public static IsapiTestResult Online(string? deviceName, string? model, string? firmware, IReadOnlyList logLines) => + new(ConnectionTestOutcome.Online, "", deviceName, model, firmware, logLines); - public static IsapiTestResult Failed(string reason) => new(false, reason, null, null, null); + public static IsapiTestResult ApiResponseError(string reason, string? deviceName, string? model, string? firmware, IReadOnlyList logLines) => + new(ConnectionTestOutcome.ApiResponseError, reason, deviceName, model, firmware, logLines); + + public static IsapiTestResult AuthenticationFailed(string reason, IReadOnlyList logLines) => + new(ConnectionTestOutcome.AuthenticationFailed, reason, null, null, null, logLines); + + public static IsapiTestResult Timeout(string reason, IReadOnlyList logLines) => + new(ConnectionTestOutcome.Timeout, reason, null, null, null, logLines); + + public static IsapiTestResult Offline(string reason, IReadOnlyList logLines) => + new(ConnectionTestOutcome.Offline, reason, null, null, null, logLines); + + public static IsapiTestResult CredentialsMissing(string reason) => + new(ConnectionTestOutcome.CredentialsMissing, reason, null, null, null, Array.Empty()); } public sealed record ApiResult(bool Success, string Reason) @@ -527,3 +1154,7 @@ public sealed record ApiResult(bool Success, string Reason) public static ApiResult Succeeded() => new(true, ""); public static ApiResult Failed(string reason) => new(false, reason); } + +public sealed record UserDeleteResult(string EmployeeNumber, ApiResult Result); + +public sealed record FaceLibraryInfo(string FaceLibType, string Fdid);