530 lines
25 KiB
C#
530 lines
25 KiB
C#
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using HikvisionAttendanceManager.App.Models;
|
|
|
|
namespace HikvisionAttendanceManager.App.Services;
|
|
|
|
/// <summary>Direct ISAPI client. It does not use or communicate with HikvisionAttendanceService.</summary>
|
|
public sealed class HikvisionIsapiClient
|
|
{
|
|
private const string FaceLibraryType = "blackFD";
|
|
private const string FaceLibraryId = "1";
|
|
|
|
public async Task<IsapiTestResult> TestConnectionAsync(Device device, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryGetCredentials(device, out var username, out var password, out var credentialError))
|
|
return IsapiTestResult.Failed(credentialError);
|
|
|
|
using var client = CreateTestClient(device, username, password);
|
|
try
|
|
{
|
|
using var response = await client.GetAsync("/ISAPI/System/deviceInfo?format=json", cancellationToken).ConfigureAwait(false);
|
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
return IsapiTestResult.Failed("Invalid credentials (ISAPI authentication failed).");
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
return IsapiTestResult.Failed($"ISAPI request failed (HTTP {(int)response.StatusCode}).");
|
|
|
|
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.");
|
|
}
|
|
}
|
|
|
|
private static bool TryGetCredentials(Device device, out string username, out string password, out string error)
|
|
{
|
|
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 = "";
|
|
return true;
|
|
}
|
|
|
|
private static HttpClient CreateTestClient(Device device, string username, string password) =>
|
|
new(new SocketsHttpHandler
|
|
{
|
|
Credentials = new NetworkCredential(username, password),
|
|
PreAuthenticate = false,
|
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
|
PooledConnectionLifetime = TimeSpan.Zero
|
|
})
|
|
{
|
|
BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"),
|
|
Timeout = TimeSpan.FromSeconds(10)
|
|
};
|
|
|
|
private static HttpClient CreateClient(Device device)
|
|
{
|
|
if (!TryGetCredentials(device, out var username, out var password, out var error))
|
|
throw new InvalidOperationException(error);
|
|
|
|
return new HttpClient(new HttpClientHandler
|
|
{
|
|
Credentials = new NetworkCredential(username, password),
|
|
PreAuthenticate = false,
|
|
UseDefaultCredentials = false
|
|
}) { BaseAddress = new Uri($"http://{device.IpAddress}:{device.IsapiPort}"), Timeout = TimeSpan.FromSeconds(90) };
|
|
}
|
|
|
|
public async Task<bool> UserExistsAsync(Device device, string employeeNo, CancellationToken cancellationToken)
|
|
{
|
|
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);
|
|
return response.IsSuccessStatusCode && body.Contains(employeeNo, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public async Task<ApiResult> CreateUserAsync(Device device, HrmsEmployee employee, CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient(device);
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
UserInfo = new
|
|
{
|
|
employeeNo = employee.SerialNumber,
|
|
name = string.IsNullOrWhiteSpace(employee.Name) ? employee.SerialNumber : employee.Name,
|
|
userType = "normal",
|
|
Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" },
|
|
doorRight = "1",
|
|
RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } }
|
|
}
|
|
});
|
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
|
|
return await ToResultAsync(response, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Lightweight user count via UserInfo Search totalMatches (maxResults=1).</summary>
|
|
public async Task<int?> TryGetUserCountAsync(Device device, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryGetCredentials(device, out var username, out var password, out _))
|
|
return null;
|
|
|
|
using var client = CreateTestClient(device, username, password);
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
UserInfoSearchCond = new
|
|
{
|
|
searchID = "1",
|
|
searchResultPosition = 0,
|
|
maxResults = 1
|
|
}
|
|
});
|
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return null;
|
|
|
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
|
var total = FindIntProperty(document.RootElement, "totalMatches", "numOfMatches", "totalMatch");
|
|
return total >= 0 ? total : null;
|
|
}
|
|
|
|
public async Task<ApiResult> CreateUserAsync(Device device, string employeeNo, string name, CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient(device);
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
UserInfo = new
|
|
{
|
|
employeeNo,
|
|
name = string.IsNullOrWhiteSpace(name) ? employeeNo : name,
|
|
userType = "normal",
|
|
Valid = new { enable = true, beginTime = "2000-01-01T00:00:00", endTime = "2037-12-31T23:59:59" },
|
|
doorRight = "1",
|
|
RightPlan = new[] { new { doorNo = 1, planTemplateNo = "1" } }
|
|
}
|
|
});
|
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Record?format=json", payload, cancellationToken);
|
|
return await ToResultAsync(response, 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.");
|
|
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);
|
|
}
|
|
|
|
public async Task<ApiResult> 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.");
|
|
}
|
|
|
|
public async Task<ApiResult> DeleteUsersAsync(Device device, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
|
|
{
|
|
if (employeeNumbers.Count == 0) return ApiResult.Succeeded();
|
|
using var client = CreateClient(device);
|
|
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 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}")));
|
|
}
|
|
}
|
|
return ApiResult.Succeeded();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<HikvisionUser>> GetUsersAsync(Device device, CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient(device);
|
|
var users = new List<HikvisionUser>();
|
|
const int pageSize = 100;
|
|
for (var position = 0; ; position += pageSize)
|
|
{
|
|
var payload = JsonSerializer.Serialize(new { UserInfoSearchCond = new { searchID = "1", searchResultPosition = position, maxResults = pageSize } });
|
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/UserInfo/Search?format=json", payload, cancellationToken);
|
|
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Unable to read device users (HTTP {(int)response.StatusCode}).");
|
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
|
var page = FindUsers(document.RootElement);
|
|
users.AddRange(page);
|
|
if (page.Count < pageSize) break;
|
|
}
|
|
return users;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<AttendancePunch>> FetchAcsEventsAsync(Device device, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient(device);
|
|
const uint major = 0;
|
|
const uint minor = 0;
|
|
var searchId = "1";
|
|
var startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
|
|
var endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00";
|
|
var punches = new List<AttendancePunch>();
|
|
var searchResultPosition = 0;
|
|
var maxResults = 30;
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
AcsEventCond = new
|
|
{
|
|
searchID = searchId,
|
|
searchResultPosition,
|
|
maxResults,
|
|
major,
|
|
minor,
|
|
startTime,
|
|
endTime
|
|
}
|
|
});
|
|
using var response = await SendJsonAsync(client, HttpMethod.Post, "/ISAPI/AccessControl/AcsEvent?format=json", payload, cancellationToken);
|
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
throw new InvalidOperationException($"ACS event fetch failed (HTTP {(int)response.StatusCode}).");
|
|
|
|
using var document = JsonDocument.Parse(body);
|
|
var status = FindStringProperty(document.RootElement, "responseStatusStrg", "responseStatusStr", "responseStatusString");
|
|
var events = ExtractAcsEvents(document.RootElement);
|
|
foreach (var ev in events)
|
|
{
|
|
if (TryParseAttendancePunch(ev, out var punch))
|
|
punches.Add(punch);
|
|
}
|
|
|
|
if (events.Count < maxResults || string.Equals(status, "END", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "NO MATCH", StringComparison.OrdinalIgnoreCase))
|
|
break;
|
|
|
|
searchResultPosition += events.Count;
|
|
if (searchResultPosition > 0 && searchResultPosition % 90 == 0) maxResults = Math.Min(100, maxResults + 10);
|
|
}
|
|
|
|
return punches;
|
|
}
|
|
|
|
public async Task<ApiResultWithBytes> DownloadFaceTemplateAsync(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 ApiResultWithBytes.Failed($"FDSearch failed (HTTP {(int)response.StatusCode}).");
|
|
|
|
var faceUrl = FindStringProperty(JsonDocument.Parse(body).RootElement, "faceURL", "faceUrl", "pictureURL", "pictureUrl");
|
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
|
return ApiResultWithBytes.Failed("No face template URL returned by device.");
|
|
|
|
var path = faceUrl;
|
|
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var absolute))
|
|
path = absolute.PathAndQuery;
|
|
|
|
using var imageResponse = await client.GetAsync(path, cancellationToken);
|
|
if (!imageResponse.IsSuccessStatusCode)
|
|
return ApiResultWithBytes.Failed($"Face image download failed (HTTP {(int)imageResponse.StatusCode}).");
|
|
|
|
var bytes = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
return IsJpeg(bytes)
|
|
? ApiResultWithBytes.Succeeded(bytes)
|
|
: ApiResultWithBytes.Failed("Downloaded face data is not a JPEG template.");
|
|
}
|
|
|
|
public static bool IsJpeg(byte[] bytes) => bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
|
|
|
public static bool IsUploadableFaceTemplate(byte[] bytes) => IsJpeg(bytes);
|
|
|
|
private static List<HikvisionUser> FindUsers(JsonElement element)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (element.TryGetProperty("UserInfo", out var users) && users.ValueKind == JsonValueKind.Array)
|
|
return users.EnumerateArray().Select(item => new HikvisionUser(
|
|
item.TryGetProperty("employeeNo", out var employeeNo) ? employeeNo.ToString() : "",
|
|
item.TryGetProperty("name", out var name) ? name.ToString() : "",
|
|
item.TryGetProperty("numOfFace", out var faces) && faces.TryGetInt32(out var count) ? count : 0))
|
|
.Where(user => !string.IsNullOrWhiteSpace(user.EmployeeNo)).ToList();
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
var found = FindUsers(property.Value);
|
|
if (found.Count > 0) return found;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
var found = FindUsers(item);
|
|
if (found.Count > 0) return found;
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private static List<JsonElement> ExtractAcsEvents(JsonElement element)
|
|
{
|
|
var results = new List<JsonElement>();
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (element.TryGetProperty("AcsEvent", out var acs) && acs.TryGetProperty("InfoList", out var infoList) && infoList.ValueKind == JsonValueKind.Array)
|
|
results.AddRange(infoList.EnumerateArray());
|
|
foreach (var property in element.EnumerateObject())
|
|
results.AddRange(ExtractAcsEvents(property.Value));
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in element.EnumerateArray())
|
|
results.AddRange(ExtractAcsEvents(item));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private static bool TryParseAttendancePunch(JsonElement info, out AttendancePunch punch)
|
|
{
|
|
punch = default!;
|
|
var employeeNo = FindStringProperty(info, "employeeNoString", "employeeNo", "employeeNoStr");
|
|
if (string.IsNullOrWhiteSpace(employeeNo) || !int.TryParse(employeeNo.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
|
return false;
|
|
|
|
if (!TryExtractEventTime(info, out var checkTime))
|
|
checkTime = DateTime.Now;
|
|
|
|
var minor = info.TryGetProperty("minor", out var minorProp) && minorProp.TryGetUInt32(out var minorValue)
|
|
? (int)minorValue
|
|
: 0;
|
|
punch = new AttendancePunch(employeeNo.Trim(), checkTime, minor);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryExtractEventTime(JsonElement info, out DateTime dt)
|
|
{
|
|
foreach (var key in new[] { "time", "eventTime", "verifyTime", "statusTime", "attendanceTime" })
|
|
{
|
|
if (info.TryGetProperty(key, out var value) && TryParseDateTime(value, out dt))
|
|
return true;
|
|
}
|
|
|
|
foreach (var property in info.EnumerateObject())
|
|
{
|
|
if (property.Name.Contains("time", StringComparison.OrdinalIgnoreCase) && TryParseDateTime(property.Value, out dt))
|
|
return true;
|
|
}
|
|
|
|
dt = default;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryParseDateTime(JsonElement value, out DateTime dt)
|
|
{
|
|
if (value.ValueKind == JsonValueKind.String)
|
|
{
|
|
var text = value.GetString() ?? "";
|
|
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt))
|
|
return true;
|
|
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out dt))
|
|
return true;
|
|
}
|
|
dt = default;
|
|
return false;
|
|
}
|
|
|
|
private static string FindStringProperty(JsonElement element, params string[] keys)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
|
{
|
|
var text = value.GetString();
|
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
|
}
|
|
}
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
var found = FindStringProperty(property.Value, keys);
|
|
if (!string.IsNullOrWhiteSpace(found)) return found;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
var found = FindStringProperty(item, keys);
|
|
if (!string.IsNullOrWhiteSpace(found)) return found;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
private static int FindIntProperty(JsonElement element, params string[] keys)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (element.TryGetProperty(key, out var value))
|
|
{
|
|
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
|
return number;
|
|
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number))
|
|
return number;
|
|
}
|
|
}
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
var found = FindIntProperty(property.Value, keys);
|
|
if (found >= 0) return found;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
var found = FindIntProperty(item, keys);
|
|
if (found >= 0) return found;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
private static async Task<HttpResponseMessage> SendJsonAsync(HttpClient client, HttpMethod method, string uri, string json, CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(method, uri) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
|
return await client.SendAsync(request, cancellationToken);
|
|
}
|
|
|
|
private static async Task<ApiResult> 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}"));
|
|
}
|
|
|
|
private static bool IsSuccessfulIsapiBody(string body) =>
|
|
string.IsNullOrWhiteSpace(body) || body.Contains("\"statusCode\":1") || body.Contains("\"statusString\":\"OK\"", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string ExtractReason(string body, string fallback)
|
|
{
|
|
foreach (var key in new[] { "subStatusCode", "statusString", "errorMsg" })
|
|
{
|
|
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", " ");
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
private static byte[] BuildFaceMultipart(string boundary, string metadata, byte[] jpeg)
|
|
{
|
|
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");
|
|
return stream.ToArray();
|
|
}
|
|
}
|
|
|
|
public sealed record IsapiTestResult(bool Success, string Reason, string? DeviceName, string? Model, string? Firmware)
|
|
{
|
|
public static IsapiTestResult Succeeded(string? deviceName, string? model, string? firmware) =>
|
|
new(true, "", deviceName, model, firmware);
|
|
|
|
public static IsapiTestResult Failed(string reason) => new(false, reason, null, null, null);
|
|
}
|
|
|
|
public sealed record ApiResult(bool Success, string Reason)
|
|
{
|
|
public static ApiResult Succeeded() => new(true, "");
|
|
public static ApiResult Failed(string reason) => new(false, reason);
|
|
}
|