feat(isapi): rewrite ISAPI client for connection test, delete, and face enrollment
XML/JSON connection test, delete success parsing (subStatusCode: ok), FDLib discovery, hand-built multipart face upload, and UserInfo numOfFace verification.main
parent
2b3e8ea619
commit
85ac3c7b3f
|
|
@ -4,6 +4,8 @@ using System.Net.Http;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
using HikvisionAttendanceManager.App.Models;
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
namespace HikvisionAttendanceManager.App.Services;
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
@ -11,64 +13,204 @@ namespace HikvisionAttendanceManager.App.Services;
|
||||||
/// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary>
|
/// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary>
|
||||||
public sealed class HikvisionIsapiClient
|
public sealed class HikvisionIsapiClient
|
||||||
{
|
{
|
||||||
private const string FaceLibraryType = "blackFD";
|
private const string FaceDataRecordPath = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json";
|
||||||
private const string FaceLibraryId = "1";
|
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<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken)
|
public async Task<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!TryGetCredentials(device, out var username, out var password, out var credentialError))
|
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);
|
using var client = CreateTestClient(device, username, password);
|
||||||
|
var logLines = new List<string>();
|
||||||
|
|
||||||
|
foreach (var path in new[] { "/ISAPI/System/deviceInfo", "/ISAPI/System/deviceInfo?format=json" })
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var requestUri = new Uri(client.BaseAddress!, path).AbsoluteUri;
|
||||||
|
logLines.Add($"request={requestUri}");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false);
|
using var response = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
var body = await response.Content.ReadAsStringAsync(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.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed).");
|
return IsapiTestResult.AuthenticationFailed("Authentication failed (invalid Hikvision credentials).", logLines);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound && path.Contains("format=json", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode}).");
|
return IsapiTestResult.Offline($"ISAPI request failed (HTTP {(int)response.StatusCode}).", logLines);
|
||||||
|
|
||||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
if (TryParseDeviceInfo(body, contentType, device, out var deviceName, out var model, out var firmware, out var parseNote))
|
||||||
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 (!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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetCredentials(Device device, out string username, out string password, out string error)
|
return IsapiTestResult.Offline("Device unreachable. Check the IP address and network connection.", logLines);
|
||||||
{
|
|
||||||
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))
|
|
||||||
{
|
|
||||||
error = "Hikvision credentials are not configured for this device.";
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
error = "";
|
|
||||||
|
private static bool TryParseDeviceInfo(
|
||||||
|
string body,
|
||||||
|
string contentType,
|
||||||
|
Device device,
|
||||||
|
out string? deviceName,
|
||||||
|
out string? model,
|
||||||
|
out string? firmware,
|
||||||
|
out string? parseNote)
|
||||||
|
{
|
||||||
|
deviceName = null;
|
||||||
|
model = null;
|
||||||
|
firmware = null;
|
||||||
|
parseNote = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
deviceName = device.Name;
|
||||||
|
parseNote = "Device responded with an empty body; connectivity and authentication succeeded.";
|
||||||
return true;
|
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) =>
|
private static HttpClient CreateTestClient(Device device, string username, string password) =>
|
||||||
new(new SocketsHttpHandler
|
new(new SocketsHttpHandler
|
||||||
{
|
{
|
||||||
Credentials = new NetworkCredential(username, password),
|
Credentials = new NetworkCredential(username, password),
|
||||||
PreAuthenticate = false,
|
PreAuthenticate = false,
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
ConnectTimeout = TimeSpan.FromSeconds(TestTimeoutSeconds),
|
||||||
PooledConnectionLifetime = TimeSpan.Zero
|
PooledConnectionLifetime = TimeSpan.Zero
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"),
|
BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"),
|
||||||
Timeout = TimeSpan.FromSeconds(10)
|
Timeout = TimeSpan.FromSeconds(TestTimeoutSeconds)
|
||||||
};
|
};
|
||||||
|
|
||||||
private static HttpClient CreateClient(Device device)
|
private static HttpClient CreateClient(Device device)
|
||||||
|
|
@ -86,6 +228,7 @@ public sealed class HikvisionIsapiClient
|
||||||
|
|
||||||
public async Task<bool> UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
public async Task<bool> 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);
|
using var client = CreateClient(device);
|
||||||
var json = JsonSerializer.Serialize(new
|
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);
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", json, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(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<ApiResult> CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken)
|
public async Task<ApiResult> 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);
|
using var client = CreateClient(device);
|
||||||
var payload = JsonSerializer.Serialize(new
|
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);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary>
|
/// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary>
|
||||||
|
|
@ -148,6 +298,8 @@ public sealed class HikvisionIsapiClient
|
||||||
|
|
||||||
public async Task<ApiResult> CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken)
|
public async Task<ApiResult> 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);
|
using var client = CreateClient(device);
|
||||||
var payload = JsonSerializer.Serialize(new
|
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);
|
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<ApiResult> UploadFaceAsync(Device device, string employeeNo, byte[] jpeg, CancellationToken cancellationToken)
|
public async Task<ApiResult> 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);
|
using var client = CreateClient(device);
|
||||||
var boundary = "---------------" + Guid.NewGuid().ToString("N");
|
var libraries = await DiscoverFaceLibrariesAsync(client, cancellationToken).ConfigureAwait(false);
|
||||||
var metadata = $$"""{"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}"}""";
|
if (libraries.Count == 0)
|
||||||
var body = BuildFaceMultipart(boundary, metadata, jpeg);
|
{
|
||||||
using var content = new ByteArrayContent(body);
|
AppLogger.Warning("[ISAPI] FDLib discovery returned no libraries.");
|
||||||
content.Headers.TryAddWithoutValidation("Content-Type", $"multipart/form-data; boundary={boundary}");
|
AppLogger.TraceExit("ISAPI", nameof(UploadFaceAsync), $"employee={employeeNo} success=false reason=no-fdlib");
|
||||||
using var response = await client.PostAsync("/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", content, cancellationToken);
|
return ApiResult.Failed("Face library discovery failed. The device did not return any FDLib entries.");
|
||||||
return await ToResultAsync(response, cancellationToken);
|
}
|
||||||
|
|
||||||
|
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<ApiResult> VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
public async Task<ApiResult> VerifyFaceAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var client = CreateClient(device);
|
AppLogger.TraceEnter("ISAPI", nameof(VerifyFaceAsync),
|
||||||
var payload = $$"""{"searchID":"1","searchResultPosition":0,"maxResults":10,"faceLibType":"{{FaceLibraryType}}","FDID":"{{FaceLibraryId}}","FPID":"{{employeeNo}}","gender":"any","certificateType":"ID"}""";
|
$"device={device.IpAddress} employee={employeeNo} path=/ISAPI/AccessControl/UserInfo/Search");
|
||||||
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken);
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
{
|
||||||
if (!response.IsSuccessStatusCode) return ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}"));
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase)
|
if (attempt > 0)
|
||||||
? ApiResult.Succeeded()
|
await Task.Delay(FaceVerificationRetryDelayMs, cancellationToken).ConfigureAwait(false);
|
||||||
: ApiResult.Failed("Face enrollment could not be verified by this device.");
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResult> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
|
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<int?> TryGetUserFaceCountAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (employeeNumbers.Count == 0) return ApiResult.Succeeded();
|
|
||||||
using var client = CreateClient(device);
|
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<IReadOnlyList<UserDeleteResult>> DeleteUsersAsync(Device device, IReadOnlyList<string> 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<UserDeleteResult>();
|
||||||
const int batchSize = 30;
|
const int batchSize = 30;
|
||||||
for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize)
|
for (var offset = 0; offset < employeeNumbers.Count; offset += batchSize)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var slice = employeeNumbers.Skip(offset).Take(batchSize).Select(e => new { employeeNo = e }).ToArray();
|
var slice = employeeNumbers.Skip(offset).Take(batchSize).ToList();
|
||||||
var payload = JsonSerializer.Serialize(new { UserInfoDelCond = new { EmployeeNoList = slice } });
|
var batchResult = await TryDeleteEmployeeBatchAsync(client, slice, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (batchResult.Success)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ApiResult> TryDeleteEmployeeBatchAsync(HttpClient client, IReadOnlyList<string> 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);
|
using var response = await SendJsonAsync(client, HttpMethod.Put, "/ISAPI/AccessControl/UserInfo/Delete?format=json", payload, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode || !IsSuccessfulIsapiBody(body))
|
var httpStatus = (int)response.StatusCode;
|
||||||
|
if (IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _))
|
||||||
{
|
{
|
||||||
var fallback = JsonSerializer.Serialize(new
|
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 = slice }
|
UserInfoDetail = new { mode = "byEmployeeNo", EmployeeNoList = list }
|
||||||
});
|
});
|
||||||
using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put,
|
using var fallbackResponse = await SendJsonAsync(client, HttpMethod.Put,
|
||||||
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallback, cancellationToken);
|
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json", fallbackPayload, cancellationToken);
|
||||||
var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken);
|
var fallbackBody = await fallbackResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!fallbackResponse.IsSuccessStatusCode || !IsSuccessfulIsapiBody(fallbackBody))
|
var fallbackHttpStatus = (int)fallbackResponse.StatusCode;
|
||||||
return ApiResult.Failed(ExtractReason(fallbackBody, ExtractReason(body, $"HTTP {(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();
|
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<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
AppLogger.TraceEnter("ISAPI", nameof(GetUsersAsync),
|
||||||
|
$"device={device.IpAddress} path=/ISAPI/AccessControl/UserInfo/Search");
|
||||||
using var client = CreateClient(device);
|
using var client = CreateClient(device);
|
||||||
var users = new List<HikvisionUser>();
|
var users = new List<HikvisionUser>();
|
||||||
const int pageSize = 100;
|
const int pageSize = 100;
|
||||||
|
|
@ -233,11 +506,14 @@ public sealed class HikvisionIsapiClient
|
||||||
users.AddRange(page);
|
users.AddRange(page);
|
||||||
if (page.Count < pageSize) break;
|
if (page.Count < pageSize) break;
|
||||||
}
|
}
|
||||||
|
AppLogger.TraceExit("ISAPI", nameof(GetUsersAsync), $"count={users.Count}");
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AttendancePunch>> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<AttendancePunch>> 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);
|
using var client = CreateClient(device);
|
||||||
const uint major = 0;
|
const uint major = 0;
|
||||||
const uint minor = 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);
|
if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLogger.TraceExit("ISAPI", nameof(FetchAcsEventsAsync), $"count={punches.Count}");
|
||||||
return punches;
|
return punches;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResultWithBytes> DownloadFaceTemplateAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
public async Task<ApiResultWithBytes> 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);
|
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);
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/Intelligent/FDLib/FDSearch?format=json", payload, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
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}).");
|
return ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode}).");
|
||||||
|
}
|
||||||
|
|
||||||
var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl");
|
var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl");
|
||||||
if (string.IsNullOrWhiteSpace(faceUrl))
|
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.");
|
return ApiResultWithBytes.Failed("No face template URL returned by device.");
|
||||||
|
}
|
||||||
|
|
||||||
var path = faceUrl;
|
var path = faceUrl;
|
||||||
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute))
|
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);
|
using var imageResponse = await client.GetAsync(path, cancellationToken);
|
||||||
if (!imageResponse.IsSuccessStatusCode)
|
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}).");
|
return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode}).");
|
||||||
|
}
|
||||||
|
|
||||||
var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
return IsJpeg(bytes)
|
var downloadResult = IsJpeg(bytes)
|
||||||
? ApiResultWithBytes.Succeeded(bytes)
|
? ApiResultWithBytes.Succeeded(bytes)
|
||||||
: ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template.");
|
: 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;
|
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<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
private static async Task<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
return response.IsSuccessStatusCode && IsSuccessfulIsapiBody(body)
|
var httpStatus = (int)response.StatusCode;
|
||||||
? ApiResult.Succeeded()
|
if (response.IsSuccessStatusCode &&
|
||||||
: ApiResult.Failed(ExtractReason(body, $"HTTP {(int)response.StatusCode}"));
|
(string.IsNullOrWhiteSpace(body) || IsConfirmedIsapiSuccess(body, httpStatus, out _, out _, out _)))
|
||||||
|
return ApiResult.Succeeded();
|
||||||
|
|
||||||
|
return ApiResult.Failed(DescribeIsapiFailure(body, httpStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsSuccessfulIsapiBody(string body) =>
|
/// <summary>HTTP 2xx + Hikvision statusCode 1 + statusString OK or subStatusCode ok.</summary>
|
||||||
string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase);
|
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*\"?(?<v>-?\\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*\"(?<v>[^\"]*)\"", RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success)
|
||||||
|
statusString = match.Groups["v"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(subStatusCode))
|
||||||
|
{
|
||||||
|
var match = Regex.Match(body, "\"subStatusCode\"\\s*:\\s*\"(?<v>[^\"]*)\"", 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)
|
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 reason = DescribeParsedIsapiFailure(200, statusCode, statusString, subStatusCode);
|
||||||
var index = body.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
if (!string.Equals(reason, "Device rejected the ISAPI request.", StringComparison.Ordinal))
|
||||||
if (index >= 0) return body.Substring(index, Math.Min(160, body.Length - index)).Replace("\r", " ").Replace("\n", " ");
|
return reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
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<IReadOnlyList<FaceLibraryInfo>> 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<FaceLibraryInfo> OrderFaceLibraries(IReadOnlyList<FaceLibraryInfo> 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<ApiResult> 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<FaceLibraryInfo> ParseFaceLibraryCandidates(string body)
|
||||||
|
{
|
||||||
|
var candidates = new List<FaceLibraryInfo>();
|
||||||
|
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*\"(?<type>[^\"]+)\".*?\"FDID\"\\s*:\\s*\"?(?<fdid>[^\",}]+)\"?", 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<FaceLibraryInfo> 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<FaceLibraryInfo> 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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hand-built multipart body matching Hikvision FaceDataRecord requirements.</summary>
|
||||||
|
private static byte[] BuildHikvisionFaceMultipartBody(string boundary, string faceDataRecordJson, byte[] jpegBytes)
|
||||||
{
|
{
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
void Write(string text) { var bytes = Encoding.UTF8.GetBytes(text); stream.Write(bytes); }
|
void WriteAscii(string text)
|
||||||
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");
|
var bytes = Encoding.ASCII.GetBytes(text);
|
||||||
stream.Write(metadataBytes);
|
stream.Write(bytes, 0, bytes.Length);
|
||||||
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");
|
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();
|
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<string> LogLines)
|
||||||
{
|
{
|
||||||
public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) =>
|
public static IsapiTestResult Online(string? deviceName, string? model, string? firmware, IReadOnlyList<string> logLines) =>
|
||||||
new(true, "", deviceName, model, firmware);
|
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<string> logLines) =>
|
||||||
|
new(ConnectionTestOutcome.ApiResponseError, reason, deviceName, model, firmware, logLines);
|
||||||
|
|
||||||
|
public static IsapiTestResult AuthenticationFailed(string reason, IReadOnlyList<string> logLines) =>
|
||||||
|
new(ConnectionTestOutcome.AuthenticationFailed, reason, null, null, null, logLines);
|
||||||
|
|
||||||
|
public static IsapiTestResult Timeout(string reason, IReadOnlyList<string> logLines) =>
|
||||||
|
new(ConnectionTestOutcome.Timeout, reason, null, null, null, logLines);
|
||||||
|
|
||||||
|
public static IsapiTestResult Offline(string reason, IReadOnlyList<string> logLines) =>
|
||||||
|
new(ConnectionTestOutcome.Offline, reason, null, null, null, logLines);
|
||||||
|
|
||||||
|
public static IsapiTestResult CredentialsMissing(string reason) =>
|
||||||
|
new(ConnectionTestOutcome.CredentialsMissing, reason, null, null, null, Array.Empty<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ApiResult(bool Success, string Reason)
|
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 Succeeded() => new(true, "");
|
||||||
public static ApiResult Failed(string reason) => new(false, reason);
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue