726 lines
30 KiB
C#
726 lines
30 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Web.Script.Serialization;
|
|
|
|
namespace HikvisionAttendanceService;
|
|
|
|
/// <summary>Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.</summary>
|
|
internal sealed partial class HikvisionAttendanceManager
|
|
{
|
|
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
|
|
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
|
|
|
|
private int IsapiPort => _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
|
|
|
|
private HikvisionAttendanceWindowsService.DeviceConfig? ResolveDeviceConfig(string deviceId)
|
|
{
|
|
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
|
|
if (key.Length == 0)
|
|
return null;
|
|
foreach (var d in _config.Devices ?? Enumerable.Empty<HikvisionAttendanceWindowsService.DeviceConfig>())
|
|
{
|
|
if (DeviceIdentity.CanonicalLookupKey(d.DeviceId) == key)
|
|
return d;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Runs one full sync cycle: source UserInfo/Search, per-target reconcile, optional deletes.</summary>
|
|
public void RunUserFaceSyncCycle(CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var pol = SyncPol;
|
|
int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries;
|
|
|
|
var source = ResolveDeviceConfig(_config.SourceDeviceId ?? "");
|
|
if (source == null || string.IsNullOrWhiteSpace(source.Ip))
|
|
{
|
|
_logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" +
|
|
(_config.SourceDeviceId ?? "") + "\".");
|
|
return;
|
|
}
|
|
|
|
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty<string>())
|
|
{
|
|
var t = ResolveDeviceConfig(tid);
|
|
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
|
|
{
|
|
_logger.Warn("UserSync: target skipped — not found or IP empty. TargetDeviceId=\"" + tid + "\".");
|
|
continue;
|
|
}
|
|
|
|
if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId))
|
|
{
|
|
_logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + t.DeviceId + "\".");
|
|
continue;
|
|
}
|
|
|
|
targets.Add(t);
|
|
}
|
|
|
|
if (targets.Count == 0)
|
|
{
|
|
_logger.Warn("UserSync: skipped — no valid target devices.");
|
|
return;
|
|
}
|
|
|
|
var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
|
|
_logger.Info("UserSync: cycle=" + cycleId + " begin source=" + source.DeviceId + " targets=" + targets.Count +
|
|
" maxRetries=" + maxRetries + " policies: UpdateExistingUserFields=" + pol.UpdateExistingUserFields +
|
|
" UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly +
|
|
" DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource);
|
|
_logger.JobInfo("user_sync", "Cycle begin id=" + cycleId + " source=" + source.DeviceId + " targets=" + targets.Count);
|
|
|
|
var sourceUsers = FetchAllUsersIsapiForSync(source, maxRetries, ct);
|
|
_logger.Info("UserSync: cycle=" + cycleId + " sourceUsers=" + sourceUsers.Count + " device=" + source.DeviceId);
|
|
_logger.JobInfo("user_sync", "Source users=" + sourceUsers.Count + " device=" + source.DeviceId);
|
|
|
|
var faceBytesByEmployee = new Dictionary<string, byte[]?>(StringComparer.OrdinalIgnoreCase);
|
|
int faceDlOk = 0, faceDlFail = 0;
|
|
foreach (var u in sourceUsers)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(u.FaceUrl))
|
|
{
|
|
faceBytesByEmployee[u.EmployeeNo] = null;
|
|
continue;
|
|
}
|
|
|
|
if (DownloadFaceByUrl(source, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
|
|
{
|
|
faceBytesByEmployee[u.EmployeeNo] = bytes;
|
|
faceDlOk++;
|
|
TrySaveFaceCacheFile(u.EmployeeNo, bytes);
|
|
}
|
|
else
|
|
{
|
|
faceBytesByEmployee[u.EmployeeNo] = null;
|
|
faceDlFail++;
|
|
}
|
|
}
|
|
|
|
_logger.Info("UserSync: cycle=" + cycleId + " faceDownload ok=" + faceDlOk + " failOrEmpty=" + faceDlFail);
|
|
|
|
var sourceEmp = new HashSet<string>(sourceUsers.Select(s => s.EmployeeNo), StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var targetUsers = FetchAllUsersIsapiForSync(target, maxRetries, ct);
|
|
var targetMap = targetUsers.ToDictionary(x => x.EmployeeNo, x => x, StringComparer.OrdinalIgnoreCase);
|
|
var initialTargetEmployeeIds = new HashSet<string>(targetUsers.Select(u => u.EmployeeNo),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
_logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " existingUsers=" + targetUsers.Count);
|
|
|
|
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
|
|
var statusCounts = new Dictionary<UserSyncStatus, int>();
|
|
|
|
void Bump(UserSyncStatus s)
|
|
{
|
|
statusCounts.TryGetValue(s, out var n);
|
|
statusCounts[s] = n + 1;
|
|
}
|
|
|
|
foreach (var srcUser in sourceUsers)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
|
|
bool createdNew = false;
|
|
|
|
if (tgtRow == null)
|
|
{
|
|
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
|
|
{
|
|
_logger.Warn("UserSync: FailedCreate employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId +
|
|
" err=" + createErr);
|
|
failed++;
|
|
Bump(UserSyncStatus.FailedCreate);
|
|
continue;
|
|
}
|
|
|
|
created++;
|
|
createdNew = true;
|
|
tgtRow = new UserDto
|
|
{
|
|
EmployeeNo = srcUser.EmployeeNo,
|
|
Name = srcUser.Name,
|
|
NumOfFace = 0,
|
|
NumOfFp = 0
|
|
};
|
|
targetMap[srcUser.EmployeeNo] = tgtRow;
|
|
}
|
|
else if (pol.UpdateExistingUserFields && UserFieldsDiffer(srcUser, tgtRow))
|
|
{
|
|
if (TryModifyUserOnTarget(target, srcUser, maxRetries, ct, out var modErr))
|
|
{
|
|
updated++;
|
|
Bump(UserSyncStatus.ExistsUserFieldsUpdated);
|
|
tgtRow.Name = srcUser.Name;
|
|
tgtRow.ValidBeginTime = srcUser.ValidBeginTime;
|
|
tgtRow.ValidEndTime = srcUser.ValidEndTime;
|
|
tgtRow.ValidEnable = srcUser.ValidEnable;
|
|
tgtRow.UserType = srcUser.UserType;
|
|
tgtRow.DoorRight = srcUser.DoorRight;
|
|
tgtRow.RightPlanDoorNo = srcUser.RightPlanDoorNo;
|
|
tgtRow.RightPlanTemplateNo = srcUser.RightPlanTemplateNo;
|
|
}
|
|
else
|
|
{
|
|
_logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" +
|
|
target.DeviceId + " err=" + modErr);
|
|
failed++;
|
|
Bump(UserSyncStatus.FailedUserModify);
|
|
}
|
|
}
|
|
|
|
bool hasFaceBytes = faceBytesByEmployee.TryGetValue(srcUser.EmployeeNo, out var fb) && fb != null &&
|
|
fb.Length > 0;
|
|
if (!hasFaceBytes)
|
|
{
|
|
skipped++;
|
|
if (!createdNew && tgtRow.NumOfFace > 0)
|
|
Bump(UserSyncStatus.AlreadySynced);
|
|
else
|
|
Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped);
|
|
continue;
|
|
}
|
|
|
|
if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0)
|
|
{
|
|
skipped++;
|
|
Bump(UserSyncStatus.SkippedFaceUploadPolicy);
|
|
continue;
|
|
}
|
|
|
|
if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr))
|
|
{
|
|
_logger.Warn("UserSync: FailedFaceUpload employeeNo=" + srcUser.EmployeeNo + " target=" +
|
|
target.DeviceId + " err=" + upErr);
|
|
failed++;
|
|
Bump(createdNew ? UserSyncStatus.CreatedFaceUploadFailed : UserSyncStatus.FailedFaceUpload);
|
|
continue;
|
|
}
|
|
|
|
faceUp++;
|
|
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
|
|
Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded);
|
|
}
|
|
|
|
if (pol.DeleteOnTargetIfMissingInSource)
|
|
{
|
|
var toDelete = initialTargetEmployeeIds.Where(k => !sourceEmp.Contains(k)).ToList();
|
|
if (toDelete.Count > 0)
|
|
{
|
|
_logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " deleteCandidates=" +
|
|
toDelete.Count);
|
|
if (!TryDeleteUsersOnTarget(target, toDelete, maxRetries, ct, out var delErr))
|
|
{
|
|
_logger.Warn("UserSync: batch delete failed target=" + target.DeviceId + " err=" + delErr);
|
|
for (var di = 0; di < toDelete.Count; di++)
|
|
Bump(UserSyncStatus.FailedDeleteOnTarget);
|
|
}
|
|
else
|
|
{
|
|
foreach (var _ in toDelete)
|
|
Bump(UserSyncStatus.DeletedOnTargetNotInSource);
|
|
}
|
|
}
|
|
}
|
|
|
|
var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
|
|
.Select(kv => kv.Key + "=" + kv.Value));
|
|
_logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " summary created=" + created +
|
|
" faceUploaded=" + faceUp + " userFieldsUpdated=" + updated + " skippedRows=" + skipped +
|
|
" failures=" + failed + " statusCounts[" + statusLine + "]");
|
|
_logger.JobInfo("user_sync", "Target summary cycle=" + cycleId + " target=" + target.DeviceId +
|
|
" created=" + created + " faceUploaded=" + faceUp + " updated=" + updated +
|
|
" skipped=" + skipped + " failed=" + failed + " statusCounts[" + statusLine + "]");
|
|
}
|
|
|
|
_logger.Info("UserSync: cycle=" + cycleId + " completed.");
|
|
_logger.JobInfo("user_sync", "Cycle completed id=" + cycleId);
|
|
}
|
|
|
|
private async Task UserSyncSchedulerLoop(CancellationToken token)
|
|
{
|
|
bool firstRun = true;
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
if (!firstRun)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromMinutes(_config.SyncIntervalMinutes), token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
firstRun = false;
|
|
|
|
try
|
|
{
|
|
LogJobCycleStart("user_sync", "USER SYNC", "--UserSync job started at ");
|
|
RunUserFaceSyncCycle(token);
|
|
LogJobCycleEnd("user_sync", "--UserSync job finished at ");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("UserSync: cycle exception", ex);
|
|
_logger.JobError("user_sync", "Cycle exception err=" + ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>One page of <c>UserInfo/Search</c> (searchResultPosition = start, maxResults = max).</summary>
|
|
public bool SearchUsersIsapiHttp(HikvisionAttendanceWindowsService.DeviceConfig device, int start, int max,
|
|
out List<UserDto> users)
|
|
{
|
|
return TrySearchUsersIsapiPage(device, start, max, filterEmployeeNo: null, SyncPol.HttpMaxRetries,
|
|
CancellationToken.None, out users, out _);
|
|
}
|
|
|
|
public bool DownloadFaceByUrl(HikvisionAttendanceWindowsService.DeviceConfig source, string faceUrl, out byte[] bytes)
|
|
{
|
|
return DownloadFaceByUrl(source, faceUrl, SyncPol.HttpMaxRetries, CancellationToken.None, out bytes);
|
|
}
|
|
|
|
public bool DownloadFaceByUrl(HikvisionAttendanceWindowsService.DeviceConfig source, string faceUrl, int maxRetries,
|
|
CancellationToken ct, out byte[] bytes)
|
|
{
|
|
bytes = Array.Empty<byte>();
|
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
|
return false;
|
|
|
|
var cleaned = StripFaceUrlSuffix(faceUrl.Trim());
|
|
int httpPort = IsapiPort;
|
|
string pathAndQuery = cleaned;
|
|
if (Uri.TryCreate(cleaned, UriKind.Absolute, out var abs))
|
|
pathAndQuery = abs.PathAndQuery;
|
|
if (!pathAndQuery.StartsWith("/", StringComparison.Ordinal))
|
|
pathAndQuery = "/" + pathAndQuery.TrimStart('/');
|
|
|
|
string url = "http://" + source.Ip + ":" + httpPort + pathAndQuery;
|
|
return TryHttpGetBytesWithRetry(source, url, maxRetries, ct, out bytes, out _);
|
|
}
|
|
|
|
public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user)
|
|
{
|
|
return CreateUserOnTarget(target, user, SyncPol.HttpMaxRetries, CancellationToken.None, out _);
|
|
}
|
|
|
|
public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries,
|
|
CancellationToken ct, out string error)
|
|
{
|
|
error = "";
|
|
var ser = new JavaScriptSerializer();
|
|
var root = BuildUserInfoRecordPayload(user);
|
|
string json = ser.Serialize(root);
|
|
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Record?format=json", json, maxRetries, ct,
|
|
out var body, out var status, out error))
|
|
return false;
|
|
|
|
if (!IsLikelyIsapiSuccess(body, status))
|
|
{
|
|
error = "ISAPI error status=" + status + " bodySnip=" + ToOneLineSnippet(body);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
|
|
byte[] faceImage)
|
|
{
|
|
return UploadFaceOnTarget(target, employeeNo, faceImage, SyncPol.HttpMaxRetries, CancellationToken.None, out _);
|
|
}
|
|
|
|
public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
|
|
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
|
|
{
|
|
error = "";
|
|
var pol = SyncPol;
|
|
if (faceImage == null || faceImage.Length == 0)
|
|
{
|
|
error = "empty faceImage";
|
|
return false;
|
|
}
|
|
|
|
string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(pol.FaceLibType) + "\",\"FDID\":\"" +
|
|
EscapeJsonStatic(pol.FaceLibraryFdId) + "\",\"FPID\":\"" +
|
|
EscapeJsonStatic(employeeNo ?? "") + "\"}";
|
|
|
|
return TryIsapiPostMultipartFaceWithRetry(target,
|
|
"/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", metaJson, faceImage, employeeNo, maxRetries, ct,
|
|
out _, out _, out error);
|
|
}
|
|
|
|
public bool VerifyUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
|
|
out UserDto? user)
|
|
{
|
|
user = null;
|
|
if (string.IsNullOrWhiteSpace(employeeNo))
|
|
return false;
|
|
if (!TrySearchUsersIsapiPage(target, 0, 10, employeeNo.Trim(), SyncPol.HttpMaxRetries, CancellationToken.None,
|
|
out var list,
|
|
out _))
|
|
return false;
|
|
user = list.FirstOrDefault(u =>
|
|
string.Equals(u.EmployeeNo, employeeNo.Trim(), StringComparison.OrdinalIgnoreCase));
|
|
return user != null;
|
|
}
|
|
|
|
private List<UserDto> FetchAllUsersIsapiForSync(HikvisionAttendanceWindowsService.DeviceConfig device,
|
|
int maxRetries, CancellationToken ct)
|
|
{
|
|
var all = new List<UserDto>();
|
|
int pos = 0;
|
|
const int page = 100;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
if (!TrySearchUsersIsapiPage(device, pos, page, null, maxRetries, ct, out var chunk, out var err))
|
|
{
|
|
_logger.Warn("UserSync: UserInfo/Search page failed device=" + device.DeviceId + " pos=" + pos +
|
|
" err=" + err);
|
|
break;
|
|
}
|
|
|
|
if (chunk == null || chunk.Count == 0)
|
|
break;
|
|
all.AddRange(chunk);
|
|
if (chunk.Count < page)
|
|
break;
|
|
pos += chunk.Count;
|
|
}
|
|
|
|
return all;
|
|
}
|
|
|
|
private bool TrySearchUsersIsapiPage(HikvisionAttendanceWindowsService.DeviceConfig device, int start, int max,
|
|
string? filterEmployeeNo, int maxRetries, CancellationToken ct, out List<UserDto> users, out string error)
|
|
{
|
|
users = new List<UserDto>();
|
|
error = "";
|
|
string body;
|
|
if (string.IsNullOrEmpty(filterEmployeeNo))
|
|
{
|
|
body = "{\"UserInfoSearchCond\":{\"searchID\":\"1\",\"searchResultPosition\":" + start +
|
|
",\"maxResults\":" + max + "}}";
|
|
}
|
|
else
|
|
{
|
|
body = "{\"UserInfoSearchCond\":{\"searchID\":\"1\",\"searchResultPosition\":" + start +
|
|
",\"maxResults\":" + max + ",\"EmployeeNoList\":[{\"employeeNo\":\"" +
|
|
EscapeJsonStatic(filterEmployeeNo) + "\"}]}}";
|
|
}
|
|
|
|
if (!TryIsapiPostJsonWithRetry(device, "/ISAPI/AccessControl/UserInfo/Search?format=json", body, maxRetries, ct,
|
|
out var resp, out var status, out error))
|
|
return false;
|
|
|
|
if (status != (int)HttpStatusCode.OK)
|
|
{
|
|
error = "HTTP " + status;
|
|
return false;
|
|
}
|
|
|
|
users = UserSyncJsonParser.ParseUserInfoSearchPage(resp);
|
|
return true;
|
|
}
|
|
|
|
private bool TryModifyUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user,
|
|
int maxRetries, CancellationToken ct, out string error)
|
|
{
|
|
error = "";
|
|
var ser = new JavaScriptSerializer();
|
|
string json = ser.Serialize(BuildUserInfoRecordPayload(user));
|
|
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Modify?format=json", json, maxRetries, ct,
|
|
out var body, out var status, out error))
|
|
return false;
|
|
return IsLikelyIsapiSuccess(body, status);
|
|
}
|
|
|
|
private static object BuildUserInfoRecordPayload(UserDto user)
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
["UserInfo"] = new Dictionary<string, object>
|
|
{
|
|
["employeeNo"] = user.EmployeeNo,
|
|
["name"] = string.IsNullOrWhiteSpace(user.Name) ? user.EmployeeNo : user.Name,
|
|
["userType"] = string.IsNullOrWhiteSpace(user.UserType) ? "normal" : user.UserType,
|
|
["Valid"] = new Dictionary<string, object>
|
|
{
|
|
["enable"] = user.ValidEnable,
|
|
["beginTime"] = user.ValidBeginTime,
|
|
["endTime"] = user.ValidEndTime
|
|
},
|
|
["doorRight"] = user.DoorRight,
|
|
["RightPlan"] = new object[]
|
|
{
|
|
new Dictionary<string, object>
|
|
{
|
|
["doorNo"] = user.RightPlanDoorNo,
|
|
["planTemplateNo"] = user.RightPlanTemplateNo
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private static bool UserFieldsDiffer(UserDto src, UserDto tgt)
|
|
{
|
|
return !string.Equals(src.Name, tgt.Name, StringComparison.Ordinal) ||
|
|
!string.Equals(src.ValidBeginTime, tgt.ValidBeginTime, StringComparison.Ordinal) ||
|
|
!string.Equals(src.ValidEndTime, tgt.ValidEndTime, StringComparison.Ordinal) ||
|
|
src.ValidEnable != tgt.ValidEnable ||
|
|
!string.Equals(src.UserType, tgt.UserType, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(src.DoorRight, tgt.DoorRight, StringComparison.Ordinal);
|
|
}
|
|
|
|
private bool TryDeleteUsersOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target,
|
|
List<string> employeeNumbers, int maxRetries, CancellationToken ct, out string error)
|
|
{
|
|
error = "";
|
|
const int batch = 30;
|
|
for (int i = 0; i < employeeNumbers.Count; i += batch)
|
|
{
|
|
var slice = employeeNumbers.Skip(i).Take(batch).ToList();
|
|
var listObj = slice.Select(e => new Dictionary<string, object>
|
|
{ ["employeeNo"] = e }).ToList();
|
|
var root = new Dictionary<string, object>
|
|
{
|
|
["UserInfoDelCond"] = new Dictionary<string, object> { ["EmployeeNoList"] = listObj }
|
|
};
|
|
var ser = new JavaScriptSerializer();
|
|
string json = ser.Serialize(root);
|
|
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Delete?format=json", json, maxRetries,
|
|
ct, out var body, out var status, out error))
|
|
return false;
|
|
if (!IsLikelyIsapiSuccess(body, status))
|
|
{
|
|
error = "delete ISAPI status=" + status + " snip=" + ToOneLineSnippet(body);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void TrySaveFaceCacheFile(string employeeNo, byte[] bytes)
|
|
{
|
|
var dir = _config.UserSyncFaceCacheDirectory?.Trim() ?? "";
|
|
if (dir.Length == 0 || bytes == null || bytes.Length == 0)
|
|
return;
|
|
try
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
var path = Path.Combine(dir, SanitizeFileName(employeeNo) + ".jpg");
|
|
File.WriteAllBytes(path, bytes);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warn("UserSync: face cache write failed employeeNo=" + employeeNo + " err=" + ex.Message);
|
|
}
|
|
}
|
|
|
|
private static string SanitizeFileName(string s)
|
|
{
|
|
foreach (var c in Path.GetInvalidFileNameChars())
|
|
s = s.Replace(c, '_');
|
|
return string.IsNullOrWhiteSpace(s) ? "unknown" : s.Trim();
|
|
}
|
|
|
|
private static string StripFaceUrlSuffix(string raw)
|
|
{
|
|
int at = raw.IndexOf('@');
|
|
if (at > 0)
|
|
return raw.Substring(0, at);
|
|
return raw;
|
|
}
|
|
|
|
private bool TryIsapiPostJsonWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device, string relativeUri,
|
|
string jsonBody, int maxRetries, CancellationToken ct, out string responseBody, out int statusCode, out string error)
|
|
{
|
|
responseBody = "";
|
|
statusCode = 0;
|
|
error = "";
|
|
string url = "http://" + device.Ip + ":" + IsapiPort + relativeUri;
|
|
int attempts = Math.Max(1, maxRetries);
|
|
for (int attempt = 0; attempt < attempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
using var handler = BuildDigestHandler(device);
|
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
|
|
using var content = new StringContent(jsonBody ?? "", Encoding.UTF8, "application/json");
|
|
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
|
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
|
statusCode = (int)res.StatusCode;
|
|
responseBody = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
if (res.IsSuccessStatusCode)
|
|
return true;
|
|
|
|
error = "HTTP " + statusCode + " " + res.ReasonPhrase;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool TryHttpGetBytesWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device, string fullUrl,
|
|
int maxRetries, CancellationToken ct, out byte[] bytes, out string error)
|
|
{
|
|
bytes = Array.Empty<byte>();
|
|
error = "";
|
|
int attempts = Math.Max(1, maxRetries);
|
|
for (int attempt = 0; attempt < attempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
using var handler = BuildDigestHandler(device);
|
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
|
|
using var res = client.GetAsync(fullUrl).GetAwaiter().GetResult();
|
|
bytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
|
if (res.IsSuccessStatusCode && bytes.Length > 0)
|
|
return true;
|
|
|
|
error = "HTTP " + (int)res.StatusCode;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool TryIsapiPostMultipartFaceWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device,
|
|
string relativeUri, string faceDataRecordJson, byte[] image, string employeeNo, int maxRetries,
|
|
CancellationToken ct, out string responseBody, out int statusCode, out string error)
|
|
{
|
|
responseBody = "";
|
|
statusCode = 0;
|
|
error = "";
|
|
string url = "http://" + device.Ip + ":" + IsapiPort + relativeUri;
|
|
int attempts = Math.Max(1, maxRetries);
|
|
for (int attempt = 0; attempt < attempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
using var handler = BuildDigestHandler(device);
|
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) };
|
|
using var mp = new MultipartFormDataContent();
|
|
mp.Add(new StringContent(faceDataRecordJson, Encoding.UTF8, "application/json"), "FaceDataRecord");
|
|
var img = new ByteArrayContent(image);
|
|
img.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
|
mp.Add(img, "img", SanitizeFileName(employeeNo) + ".jpg");
|
|
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = mp };
|
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
|
statusCode = (int)res.StatusCode;
|
|
responseBody = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
if (res.IsSuccessStatusCode)
|
|
return true;
|
|
|
|
error = "HTTP " + statusCode + " " + res.ReasonPhrase;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
|
|
{
|
|
UserSyncRetryHelper.SleepBackoff(attempt, ct);
|
|
continue;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static HttpClientHandler BuildDigestHandler(HikvisionAttendanceWindowsService.DeviceConfig device)
|
|
{
|
|
return new HttpClientHandler
|
|
{
|
|
Credentials = new NetworkCredential(device.Username ?? "", device.Password ?? ""),
|
|
PreAuthenticate = false,
|
|
UseDefaultCredentials = false
|
|
};
|
|
}
|
|
|
|
private static bool IsLikelyIsapiSuccess(string? body, int httpStatus)
|
|
{
|
|
if (httpStatus >= 200 && httpStatus < 300)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(body))
|
|
return true;
|
|
var s = body.Trim();
|
|
if (s.IndexOf("\"statusCode\"", StringComparison.OrdinalIgnoreCase) < 0 &&
|
|
s.IndexOf("statusCode", StringComparison.OrdinalIgnoreCase) < 0)
|
|
return true;
|
|
if (s.Contains("\"statusCode\":1") || s.Contains("\"statusCode\": 1"))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|