diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs index a3643e9..e6fafe4 100644 --- a/HikvisionAttendanceManager.cs +++ b/HikvisionAttendanceManager.cs @@ -7,6 +7,8 @@ using System.Globalization; using System.Runtime.InteropServices; using System.Data.SqlClient; using System.Text; +using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using System.Web.Script.Serialization; @@ -2174,6 +2176,212 @@ internal sealed class HikvisionAttendanceManager : IDisposable return !string.IsNullOrWhiteSpace(response); } + private bool TrySearchUserInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error) + { + response = ""; + error = ""; + // Try targeted search first. + var body1 = "{ \"UserInfoSearchCond\": { " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 5, " + + "\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" + + " } }"; + var raw1 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var err1); + if (!string.IsNullOrWhiteSpace(raw1)) + { + response = raw1; + error = err1; + return true; + } + + // Fallback for firmware that expects UserInfoSearch wrapper and no employee filter. + var body2 = "{ \"UserInfoSearch\": { " + + "\"searchID\": \"1\", " + + "\"searchResultPosition\": 0, " + + "\"maxResults\": 200 " + + " } }"; + var raw2 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var err2); + response = raw2; + error = string.IsNullOrWhiteSpace(err2) ? err1 : err2; + return !string.IsNullOrWhiteSpace(response); + } + + private bool TrySearchUserInfoByEmployeeNoIsapiHttp(DeviceSession session, string employeeNo, out string response, out string error) + { + response = ""; + error = ""; + try + { + int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80; + string url = "http://" + session.Device.Ip + ":" + httpPort + "/ISAPI/AccessControl/UserInfo/Search?format=json"; + string body = "{ \"UserInfoSearchCond\": { \"searchID\": \"1\", \"searchResultPosition\": 0, \"maxResults\": 100 } }"; + + var handler = new HttpClientHandler + { + Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""), + PreAuthenticate = false, + UseDefaultCredentials = false + }; + + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }; + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + using var res = client.SendAsync(req).GetAwaiter().GetResult(); + response = res.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + if (!res.IsSuccessStatusCode) + { + error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase; + return !string.IsNullOrWhiteSpace(response); + } + + _logger.Info("ISAPI HTTP UserInfo/Search ok: device=" + session.Device.DeviceId + ", httpPort=" + httpPort + + ", responseLen=" + response.Length + ", source=HttpDigest"); + return true; + } + catch (Exception ex) + { + error = ex.Message; + _logger.Warn("ISAPI HTTP UserInfo/Search failed: device=" + session.Device.DeviceId + ", err=" + ex.Message); + return false; + } + } + + private bool TrySearchUserInfoByEmployeeNo(DeviceSession session, string employeeNo, out string response, out string error) + { + if (_config.UseIsapiHttpForUserInfo) + { + _logger.Info("UserInfo source: ISAPI HTTP Digest (UseIsapiHttpForUserInfo=true), device=" + session.Device.DeviceId); + if (TrySearchUserInfoByEmployeeNoIsapiHttp(session, employeeNo, out response, out error)) + return true; + _logger.Warn("UserInfo HTTP source failed; fallback to SDK STDXML, device=" + session.Device.DeviceId + + ", err=" + (string.IsNullOrEmpty(error) ? "-" : error)); + } + + return TrySearchUserInfoByEmployeeNoIsapi(session.UserId, employeeNo, out response, out error); + } + + private bool TryDownloadFaceImageViaIsapiHttp(DeviceSession session, string faceUrl, out byte[] imageBytes, out string error) + { + imageBytes = Array.Empty(); + error = ""; + try + { + if (string.IsNullOrWhiteSpace(faceUrl)) + { + error = "empty faceUrl"; + return false; + } + + int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80; + string pathAndQuery = faceUrl; + if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var abs)) + pathAndQuery = abs.PathAndQuery; + if (!pathAndQuery.StartsWith("/")) + pathAndQuery = "/" + pathAndQuery.TrimStart('/'); + + // Force configured device IP for HTTP calls. + string url = "http://" + session.Device.Ip + ":" + httpPort + pathAndQuery; + + var handler = new HttpClientHandler + { + Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""), + PreAuthenticate = false, + UseDefaultCredentials = false + }; + + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var res = client.SendAsync(req).GetAwaiter().GetResult(); + imageBytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + if (!res.IsSuccessStatusCode) + { + error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase; + return false; + } + return imageBytes.Length > 0; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private static bool TryExtractFaceUrlFromUserInfoSearch(string userInfoJson, string employeeNo, out string faceUrl) + { + faceUrl = ""; + if (string.IsNullOrWhiteSpace(userInfoJson)) + return false; + + try + { + var ser = new JavaScriptSerializer(); + object? root = ser.DeserializeObject(userInfoJson); + if (root == null) + return false; + + var candidateKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "faceURL", "faceUrl", "pictureURL", "pictureUrl", "photoURL", "photoUrl", "imgUrl" + }; + + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary d) + { + // Prefer extracting from matching user object. + bool employeeMatches = false; + if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null) + { + var eno = enoObj.ToString() ?? ""; + employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase); + } + + if (employeeMatches) + { + foreach (var k in candidateKeys) + { + if (d.TryGetValue(k, out var v) && v is string s && !string.IsNullOrWhiteSpace(s)) + { + faceUrl = s.Trim(); + return true; + } + } + } + + foreach (var kv in d) + { + if (kv.Value is Dictionary nd) + stack.Push(nd); + else if (kv.Value is object[] arr) + foreach (var it in arr) + if (it != null) + stack.Push(it); + } + } + else if (cur is object[] arr2) + { + foreach (var it in arr2) + if (it != null) + stack.Push(it); + } + } + } + catch + { + // best effort only + } + + return false; + } + private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError) { return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError); @@ -3271,6 +3479,7 @@ internal sealed class HikvisionAttendanceManager : IDisposable ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw); bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 || fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0; + userPayload.faceReadbackSupported = !fdSearchDisabledByCap; var faceItemDoc = new FaceTemplateExportItem(); var faceDocDebug = ""; @@ -3307,6 +3516,46 @@ internal sealed class HikvisionAttendanceManager : IDisposable userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false"; } + // Fallback: if person export/search returns a face/picture URL, download it. + if (!userPayload.face.present && + TrySearchUserInfoByEmployeeNo(session, cardNo.Trim(), out var userInfoRaw, out var userInfoErr) && + TryExtractFaceUrlFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userFaceUrl)) + { + var facePath = userFaceUrl; + if (facePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + facePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.TryCreate(facePath, UriKind.Absolute, out var u)) + facePath = u.PathAndQuery; + } + + byte[] userFaceBytes; + string userFaceErr; + bool faceDownloaded = _config.UseIsapiHttpForUserInfo + ? TryDownloadFaceImageViaIsapiHttp(session, facePath, out userFaceBytes, out userFaceErr) + : (userFaceBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out userFaceErr)).Length > 0; + + if (faceDownloaded && userFaceBytes.Length > 0) + { + userPayload.face = new FaceTemplateExportItem + { + attempted = true, + present = true, + byteLength = userFaceBytes.Length, + dataBase64 = Convert.ToBase64String(userFaceBytes), + error = "" + }; + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + + " faceSource=userInfoUrlFallback faceUrl=\"" + facePath + "\" len=" + userFaceBytes.Length); + } + else + { + faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo + + " faceSource=userInfoUrlFallbackFailed faceUrl=\"" + facePath + "\" err=" + + (string.IsNullOrEmpty(userFaceErr) ? "-" : userFaceErr)); + } + } + if (!userPayload.face.present) { // Make missing template errors explicit in the face log. diff --git a/HikvisionAttendanceService.csproj b/HikvisionAttendanceService.csproj index 5005a61..f909e87 100644 --- a/HikvisionAttendanceService.csproj +++ b/HikvisionAttendanceService.csproj @@ -38,6 +38,8 @@ + + diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs index cc57158..9c433c7 100644 --- a/HikvisionAttendanceWindowsService.cs +++ b/HikvisionAttendanceWindowsService.cs @@ -116,6 +116,17 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public int HistoricalFetchLookbackMinutes { get; set; } = 1440; + /// + /// When true, user-info/faceURL lookups use direct ISAPI HTTP (Digest auth) instead of SDK STDXML wrappers. + /// Default true because some terminals return richer UserInfo fields (e.g. faceURL) over HTTP. + /// + [DataMember] + public bool UseIsapiHttpForUserInfo { get; set; } = true; + + /// HTTP port for direct ISAPI calls. 80 by default. + [DataMember] + public int IsapiHttpPort { get; set; } = 80; + public static HikvisionServiceConfig Load(string configPath) { // Minimal JSON config loader (no external packages). @@ -136,7 +147,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase AcsHistoryMajor = 0, AcsHistoryMinor = 0, HistoricalFetchIntervalMinutes = 0, - HistoricalFetchLookbackMinutes = 1440 + HistoricalFetchLookbackMinutes = 1440, + UseIsapiHttpForUserInfo = true, + IsapiHttpPort = 8000 }; } @@ -180,7 +193,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase AcsHistoryMajor = 0, AcsHistoryMinor = 0, HistoricalFetchIntervalMinutes = 0, - HistoricalFetchLookbackMinutes = 1440 + HistoricalFetchLookbackMinutes = 1440, + UseIsapiHttpForUserInfo = true, + IsapiHttpPort = 80 }; } } diff --git a/UserTemplateExportPayload.cs b/UserTemplateExportPayload.cs index 4767511..fa3abed 100644 --- a/UserTemplateExportPayload.cs +++ b/UserTemplateExportPayload.cs @@ -11,6 +11,8 @@ internal sealed class UserTemplateExportPayload /// Documents the actual SDK calls used in this build. public string sdkImplementationNote { get; set; } = "Face/Fingerprints exported via NET_DVR_StartRemoteConfig (preferred), with ISAPI fallback via NET_DVR_STDXMLConfig."; + /// True when device capabilities indicate enrolled face search/readback is supported. + public bool? faceReadbackSupported { get; set; } public FaceTemplateExportItem face { get; set; } = new FaceTemplateExportItem(); public List fingerprints { get; set; } = new List();