|
|
|
@ -10,147 +10,320 @@ import com.inspect.nvr.hikVision.utils.jna.HikVisionUtils; |
|
|
|
import com.inspect.nvr.utils.redis.RedisService; |
|
|
|
import lombok.extern.slf4j.Slf4j; |
|
|
|
import org.springframework.beans.factory.annotation.Autowired; |
|
|
|
import org.springframework.scheduling.annotation.Scheduled; |
|
|
|
import org.springframework.stereotype.Service; |
|
|
|
|
|
|
|
import javax.annotation.Resource; |
|
|
|
import java.util.ArrayList; |
|
|
|
import java.util.Map; |
|
|
|
import java.util.concurrent.ConcurrentHashMap; |
|
|
|
import java.util.concurrent.TimeUnit; |
|
|
|
|
|
|
|
/** |
|
|
|
* 海康登录服务统一管理 |
|
|
|
* 统一管理海康登录会话,负责按 NVR 复用、失效、健康检查和自动重登。 |
|
|
|
*/ |
|
|
|
@Slf4j |
|
|
|
@Service |
|
|
|
public class HikLoginService { |
|
|
|
|
|
|
|
/** Redis 中记录 SDK 登出失败事件的有序集合键。 */ |
|
|
|
private static final String ERROR_LOGOUT_KEY = "hik:error"; |
|
|
|
|
|
|
|
/** 登出失败时记录原始 SDK 错误,Redis 不可用不能阻断会话释放。 */ |
|
|
|
@Resource |
|
|
|
private RedisService redisService; |
|
|
|
|
|
|
|
/** 当前进程共享的海康 SDK 实例。 */ |
|
|
|
@Autowired |
|
|
|
private HCNetSDK hcNetSDK; |
|
|
|
|
|
|
|
// 使用 Caffeine 缓存,10分钟未访问自动移除并登出 |
|
|
|
/** 保存已登录 NVR 的服务端连接参数,供健康检查失败后自动重登。 */ |
|
|
|
private final Map<String, NvrInfo> loginTargets = new ConcurrentHashMap<>(); |
|
|
|
|
|
|
|
/** |
|
|
|
* 按 NVR IP 缓存登录句柄;同一个 key 的加载由 Caffeine 原子串行, |
|
|
|
* 不同 NVR 的首次登录可以并行。 |
|
|
|
*/ |
|
|
|
private final Cache<String, HikLoginSession> sessionCache = |
|
|
|
Caffeine.<String, HikLoginSession>newBuilder() |
|
|
|
// 10分钟未被get()就过期 |
|
|
|
.expireAfterAccess(10, TimeUnit.MINUTES) |
|
|
|
.removalListener((String ip, HikLoginSession session, RemovalCause cause) -> { |
|
|
|
if (session != null |
|
|
|
&& (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) { |
|
|
|
log.info("[海康]会话超时自动登出,ip: {},userID: {}", |
|
|
|
ip, session.getUserId()); |
|
|
|
doLogout(ip, session.getUserId()); |
|
|
|
} |
|
|
|
}).build(); |
|
|
|
.expireAfterAccess(10, TimeUnit.MINUTES) |
|
|
|
.removalListener((String ip, HikLoginSession session, RemovalCause cause) -> { |
|
|
|
// 只有缓存自然过期或容量淘汰时由监听器负责 SDK 登出。 |
|
|
|
if (session != null |
|
|
|
&& (cause == RemovalCause.EXPIRED |
|
|
|
|| cause == RemovalCause.SIZE)) { |
|
|
|
loginTargets.remove(ip); |
|
|
|
log.info("[海康]会话超时自动登出,ip: {},userID: {}", |
|
|
|
ip, session.getUserId()); |
|
|
|
// 释放 NVR 端登录连接,避免缓存淘汰后遗留设备会话。 |
|
|
|
doLogout(ip, session.getUserId()); |
|
|
|
} |
|
|
|
}).build(); |
|
|
|
|
|
|
|
/** |
|
|
|
* 登录 |
|
|
|
* 登录目标 NVR 并返回 SDK 用户句柄。 |
|
|
|
* |
|
|
|
* @param nvrInfo 数据库读取的 NVR 连接信息 |
|
|
|
* @return 海康 SDK 用户句柄 |
|
|
|
*/ |
|
|
|
public int login(NvrInfo nvrInfo) { |
|
|
|
// 统一经过会话对象入口,确保通道布局也被缓存。 |
|
|
|
return loginSession(nvrInfo).getUserId(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 登录并返回包含设备通道布局的会话。 |
|
|
|
* 登录并返回包含设备通道布局的会话;同一 NVR 的并发首次登录只执行一次。 |
|
|
|
* |
|
|
|
* @param nvrInfo 数据库读取的 NVR 连接信息 |
|
|
|
* @return 可复用的登录会话 |
|
|
|
*/ |
|
|
|
public HikLoginSession loginSession(NvrInfo nvrInfo) { |
|
|
|
String ip = nvrInfo.getNvrIp(); |
|
|
|
// 登录参数缺失时无法创建稳定缓存键,也不能安全调用原生 SDK。 |
|
|
|
if (nvrInfo == null || !hasText(nvrInfo.getNvrIp())) { |
|
|
|
throw new IllegalArgumentException("NVR IP不能为空"); |
|
|
|
} |
|
|
|
String ip = nvrInfo.getNvrIp().trim(); |
|
|
|
// 保存最近一次数据库连接参数,健康检查发现僵死句柄后用它自动重登。 |
|
|
|
loginTargets.put(ip, nvrInfo); |
|
|
|
HikLoginSession existingSession = sessionCache.getIfPresent(ip); |
|
|
|
// 缓存命中时直接返回;周期健康检查和起流失败重试负责剔除僵死句柄。 |
|
|
|
if (existingSession != null) { |
|
|
|
log.info("[海康]登录命中缓存,ip: {},userID: {}", |
|
|
|
ip, existingSession.getUserId()); |
|
|
|
return existingSession; |
|
|
|
} |
|
|
|
// 缓存命中不再经过整个 synchronized 方法;只有真正建立新会话时串行化, |
|
|
|
// 避免多个并发 PTZ 请求在缓存命中时互相排队。 |
|
|
|
synchronized (this) { |
|
|
|
existingSession = sessionCache.getIfPresent(ip); |
|
|
|
if (existingSession != null) { |
|
|
|
log.info("[海康]登录命中缓存,ip: {},userID: {}", |
|
|
|
ip, existingSession.getUserId()); |
|
|
|
return existingSession; |
|
|
|
} |
|
|
|
// Caffeine 对同一 IP 的映射计算只执行一次,避免瞬时并发重复登录设备。 |
|
|
|
return sessionCache.get(ip, ignored -> createSession(nvrInfo, ip)); |
|
|
|
} |
|
|
|
|
|
|
|
// 执行登录 |
|
|
|
HCNetSDK.NET_DVR_USER_LOGIN_INFO m_strLoginInfo = HikVisionUtils.login_V40(nvrInfo.getNvrIp(), nvrInfo.getServerPort().shortValue(), nvrInfo.getAccount(), nvrInfo.getPassword()); |
|
|
|
HCNetSDK.NET_DVR_DEVICEINFO_V40 m_strDeviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40(); |
|
|
|
int userID = hcNetSDK.NET_DVR_Login_V40(m_strLoginInfo, m_strDeviceInfo); |
|
|
|
if (userID < 0) { |
|
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
throw new RuntimeException("登录失败,错误码:" + errorCode); |
|
|
|
/** |
|
|
|
* 主动注销旧句柄并重新登录一次,供起流失败自愈使用。 |
|
|
|
* |
|
|
|
* @param nvrInfo 数据库读取的 NVR 连接信息 |
|
|
|
* @return 新建立的登录会话 |
|
|
|
*/ |
|
|
|
public HikLoginSession relogin(NvrInfo nvrInfo) { |
|
|
|
// 先失效缓存并立即登出设备端旧句柄,避免重新登录仍命中僵死会话。 |
|
|
|
logout(nvrInfo == null ? null : nvrInfo.getNvrIp()); |
|
|
|
// 使用同一份数据库凭据重新建立会话,成功后重新进入缓存。 |
|
|
|
return loginSession(nvrInfo); |
|
|
|
} |
|
|
|
|
|
|
|
/** 定时用轻量工作状态接口检查缓存句柄,失败时自动注销并重登。 */ |
|
|
|
@Scheduled(fixedDelayString = "${hik.login.health-check-interval-millis:60000}") |
|
|
|
public void healthCheckSessions() { |
|
|
|
// 遍历已知登录目标而非仅遍历会话缓存,断网重登失败后仍会在下一轮继续恢复。 |
|
|
|
for (Map.Entry<String, NvrInfo> entry |
|
|
|
: new ArrayList<>(loginTargets.entrySet())) { |
|
|
|
String ip = entry.getKey(); |
|
|
|
NvrInfo target = entry.getValue(); |
|
|
|
HikLoginSession session = sessionCache.getIfPresent(ip); |
|
|
|
// 当前存在且健康的句柄继续复用,不制造额外登录连接。 |
|
|
|
if (session != null && isSessionHealthy(ip, session)) { |
|
|
|
continue; |
|
|
|
} |
|
|
|
log.warn("[海康]登录会话健康检查失败,准备自动重登,ip: {},userID: {}", |
|
|
|
ip, session == null ? null : session.getUserId()); |
|
|
|
// 先释放缓存和设备端旧句柄,确保后续登录不会再次命中失效对象。 |
|
|
|
logout(ip); |
|
|
|
// 没有可用数据库连接参数时只能完成失效,等待下一次业务请求重新提供参数。 |
|
|
|
if (target == null) { |
|
|
|
continue; |
|
|
|
} |
|
|
|
m_strDeviceInfo.read(); |
|
|
|
HCNetSDK.NET_DVR_DEVICEINFO_V30 deviceInfo = m_strDeviceInfo.struDeviceV30; |
|
|
|
HikLoginSession session = new HikLoginSession( |
|
|
|
userID, |
|
|
|
Byte.toUnsignedInt(deviceInfo.byChanNum), |
|
|
|
Byte.toUnsignedInt(deviceInfo.byStartChan), |
|
|
|
Byte.toUnsignedInt(deviceInfo.byIPChanNum) |
|
|
|
+ Byte.toUnsignedInt(deviceInfo.byHighDChanNum) * 256, |
|
|
|
Byte.toUnsignedInt(deviceInfo.byStartDChan)); |
|
|
|
// 放入缓存,自动开始计时10分钟 |
|
|
|
sessionCache.put(nvrInfo.getNvrIp(), session); |
|
|
|
log.info("[海康]登录成功,ip:{},userID:{}", ip, userID); |
|
|
|
return session; |
|
|
|
try { |
|
|
|
// 自动重登恢复会话,设备网络恢复后无需重启容器。 |
|
|
|
loginSession(target); |
|
|
|
log.info("[海康]登录会话自动重登成功,ip: {}", ip); |
|
|
|
} catch (RuntimeException failure) { |
|
|
|
// 重登失败保留告警并等待下一轮健康检查或业务请求重试。 |
|
|
|
log.warn("[海康]登录会话自动重登失败,ip: {},原因: {}", |
|
|
|
ip, failure.getMessage()); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 对指定缓存会话调用海康轻量工作状态接口。 |
|
|
|
* |
|
|
|
* @param ip NVR IP,仅用于诊断日志 |
|
|
|
* @param session 待检查的缓存会话 |
|
|
|
* @return SDK 确认句柄有效时返回 true |
|
|
|
*/ |
|
|
|
private boolean isSessionHealthy(String ip, HikLoginSession session) { |
|
|
|
// 空会话或负数句柄必定无效,不再调用原生接口。 |
|
|
|
if (session == null || session.getUserId() < 0) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
HCNetSDK.NET_DVR_WORKSTATE_V30 workState = |
|
|
|
new HCNetSDK.NET_DVR_WORKSTATE_V30(); |
|
|
|
// 把结构体内存同步给 JNA,供 SDK 写入设备工作状态。 |
|
|
|
workState.write(); |
|
|
|
boolean healthy; |
|
|
|
try { |
|
|
|
// 轻量查询验证登录句柄和 NVR 连接是否仍然有效。 |
|
|
|
healthy = hcNetSDK.NET_DVR_GetDVRWorkState_V30( |
|
|
|
session.getUserId(), workState); |
|
|
|
} catch (RuntimeException sdkFailure) { |
|
|
|
// 原生 SDK 抛异常同样视为句柄失效,让定时任务继续执行注销和重登。 |
|
|
|
log.warn("[海康]会话健康检查调用异常,ip: {},userID: {},原因: {}", |
|
|
|
ip, session.getUserId(), sdkFailure.getMessage()); |
|
|
|
return false; |
|
|
|
} |
|
|
|
// 健康检查失败时记录原始 SDK 错误码,便于区分网络与句柄失效。 |
|
|
|
if (!healthy) { |
|
|
|
log.warn("[海康]会话失效,ip: {},userID: {},错误码: {}", |
|
|
|
ip, session.getUserId(), hcNetSDK.NET_DVR_GetLastError()); |
|
|
|
return false; |
|
|
|
} |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 记录注销失败信息到 Redis |
|
|
|
* 调用海康 SDK 建立新会话并读取设备通道布局。 |
|
|
|
* |
|
|
|
* @param nvrInfo 数据库读取的 NVR 连接信息 |
|
|
|
* @param ip 已规范化的缓存键 |
|
|
|
* @return 新登录会话 |
|
|
|
*/ |
|
|
|
private void recordLogoutError(String ip, Integer userID, int errorCode) { |
|
|
|
JSONObject json = new JSONObject(); |
|
|
|
json.put("ip", ip); |
|
|
|
json.put("userID", userID); |
|
|
|
json.put("errorCode", errorCode); |
|
|
|
json.put("time", System.currentTimeMillis()); |
|
|
|
redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis()); |
|
|
|
private HikLoginSession createSession(NvrInfo nvrInfo, String ip) { |
|
|
|
// 组装海康 V40 登录结构体,账号密码只在服务端内存和 SDK 调用中使用。 |
|
|
|
HCNetSDK.NET_DVR_USER_LOGIN_INFO loginInfo = HikVisionUtils.login_V40( |
|
|
|
ip, |
|
|
|
nvrInfo.getServerPort().shortValue(), |
|
|
|
nvrInfo.getAccount(), |
|
|
|
nvrInfo.getPassword()); |
|
|
|
HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfoV40 = |
|
|
|
new HCNetSDK.NET_DVR_DEVICEINFO_V40(); |
|
|
|
// 发起 SDK 登录并取得后续设备调用使用的用户句柄。 |
|
|
|
int userId = hcNetSDK.NET_DVR_Login_V40(loginInfo, deviceInfoV40); |
|
|
|
// 负数表示 SDK 登录失败,原始错误码保留在异常消息中供上层分类。 |
|
|
|
if (userId < 0) { |
|
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
throw new RuntimeException("登录失败,错误码:" + errorCode); |
|
|
|
} |
|
|
|
// 从原生内存读取 NVR 返回的模拟与数字通道布局。 |
|
|
|
deviceInfoV40.read(); |
|
|
|
HCNetSDK.NET_DVR_DEVICEINFO_V30 deviceInfo = deviceInfoV40.struDeviceV30; |
|
|
|
HikLoginSession session = new HikLoginSession( |
|
|
|
userId, |
|
|
|
Byte.toUnsignedInt(deviceInfo.byChanNum), |
|
|
|
Byte.toUnsignedInt(deviceInfo.byStartChan), |
|
|
|
Byte.toUnsignedInt(deviceInfo.byIPChanNum) |
|
|
|
+ Byte.toUnsignedInt(deviceInfo.byHighDChanNum) * 256, |
|
|
|
Byte.toUnsignedInt(deviceInfo.byStartDChan)); |
|
|
|
log.info("[海康]登录成功,ip: {},userID: {}", ip, userId); |
|
|
|
return session; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 登出具体实现 |
|
|
|
* 记录注销失败信息到 Redis;Redis 异常不能影响会话释放主流程。 |
|
|
|
* |
|
|
|
* @param ip NVR IP |
|
|
|
* @param userId SDK 用户句柄 |
|
|
|
* @param errorCode SDK 原始错误码 |
|
|
|
*/ |
|
|
|
public void doLogout(String ip, Integer userID) { |
|
|
|
if (userID != null) { |
|
|
|
// 调用登出SDK |
|
|
|
boolean isLogout = hcNetSDK.NET_DVR_Logout(userID); |
|
|
|
if (isLogout) { |
|
|
|
log.info("[海康]登出成功,ip: {},userID: {}", ip, userID); |
|
|
|
} else { |
|
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
log.error("[海康]登出失败,ip: {},userID: {},错误码: {}", ip, userID, errorCode); |
|
|
|
// 登出失败日志记录到Redis中 |
|
|
|
recordLogoutError(ip, userID, errorCode); |
|
|
|
} |
|
|
|
private void recordLogoutError(String ip, Integer userId, int errorCode) { |
|
|
|
// 单元测试或降级运行没有 Redis 时只保留应用日志。 |
|
|
|
if (redisService == null || redisService.redisTemplate == null) { |
|
|
|
return; |
|
|
|
} |
|
|
|
try { |
|
|
|
JSONObject json = new JSONObject(); |
|
|
|
json.put("ip", ip); |
|
|
|
json.put("userID", userId); |
|
|
|
json.put("errorCode", errorCode); |
|
|
|
json.put("time", System.currentTimeMillis()); |
|
|
|
// 按发生时间写入有序集合,供运维追查设备端未释放句柄。 |
|
|
|
redisService.redisTemplate.opsForZSet().add( |
|
|
|
ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis()); |
|
|
|
} catch (RuntimeException failure) { |
|
|
|
// Redis 暂时不可用时不反向阻塞 SDK 会话清理。 |
|
|
|
log.warn("[海康]记录登出失败事件到Redis失败,ip: {},原因: {}", |
|
|
|
ip, failure.getMessage()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 登出 |
|
|
|
* 立即调用 SDK 登出指定用户句柄。 |
|
|
|
* |
|
|
|
* @param ip NVR IP |
|
|
|
* @param userId SDK 用户句柄 |
|
|
|
*/ |
|
|
|
public synchronized void logout(String ip) { |
|
|
|
HikLoginSession session = sessionCache.getIfPresent(ip); |
|
|
|
if (session != null) { |
|
|
|
sessionCache.invalidate(ip); |
|
|
|
public void doLogout(String ip, Integer userId) { |
|
|
|
// 空句柄没有可释放的设备会话。 |
|
|
|
if (userId == null) { |
|
|
|
return; |
|
|
|
} |
|
|
|
boolean logoutSucceeded; |
|
|
|
try { |
|
|
|
// 调用海康 SDK 断开设备端登录连接。 |
|
|
|
logoutSucceeded = hcNetSDK.NET_DVR_Logout(userId); |
|
|
|
} catch (RuntimeException sdkFailure) { |
|
|
|
// 原生注销异常不能阻断缓存失效或后续重新登录。 |
|
|
|
log.error("[海康]登出调用异常,ip: {},userID: {},原因: {}", |
|
|
|
ip, userId, sdkFailure.getMessage()); |
|
|
|
return; |
|
|
|
} |
|
|
|
// SDK 确认成功时记录完整会话生命周期。 |
|
|
|
if (logoutSucceeded) { |
|
|
|
log.info("[海康]登出成功,ip: {},userID: {}", ip, userId); |
|
|
|
return; |
|
|
|
} |
|
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
log.error("[海康]登出失败,ip: {},userID: {},错误码: {}", |
|
|
|
ip, userId, errorCode); |
|
|
|
// 保留设备端登出失败证据,但 Redis 故障不影响当前调用返回。 |
|
|
|
recordLogoutError(ip, userId, errorCode); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 登出所有用户 |
|
|
|
* 失效并立即登出指定 NVR 的缓存会话。 |
|
|
|
* |
|
|
|
* @param ip NVR IP |
|
|
|
*/ |
|
|
|
public void logout(String ip) { |
|
|
|
// 空 IP 不能定位缓存会话,按幂等操作直接返回。 |
|
|
|
if (!hasText(ip)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
String normalizedIp = ip.trim(); |
|
|
|
// 原子移除会话并取得旧句柄,避免只删缓存却遗留设备端连接。 |
|
|
|
HikLoginSession session = sessionCache.asMap().remove(normalizedIp); |
|
|
|
loginTargets.remove(normalizedIp); |
|
|
|
// 缓存没有该 NVR 时保持幂等,不调用无效 SDK 句柄。 |
|
|
|
if (session == null) { |
|
|
|
return; |
|
|
|
} |
|
|
|
// 显式注销必须立即释放设备端登录会话。 |
|
|
|
doLogout(normalizedIp, session.getUserId()); |
|
|
|
} |
|
|
|
|
|
|
|
/** 登出当前进程缓存的所有海康用户。 */ |
|
|
|
public void logoutAll() { |
|
|
|
// 获取所有缓存的IP和userID |
|
|
|
sessionCache.asMap().forEach((ip, session) -> { |
|
|
|
doLogout(ip, session.getUserId()); |
|
|
|
}); |
|
|
|
// 清空整个缓存 |
|
|
|
sessionCache.invalidateAll(); |
|
|
|
// 使用 IP 快照逐个走显式登出,确保每个设备句柄都真正释放。 |
|
|
|
for (String ip : new ArrayList<>(sessionCache.asMap().keySet())) { |
|
|
|
logout(ip); |
|
|
|
} |
|
|
|
log.info("[海康]所有用户已登出"); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 检查是否已登录(同时刷新过期时间) |
|
|
|
* 检查指定 NVR 是否存在缓存会话,同时刷新访问过期时间。 |
|
|
|
* |
|
|
|
* @param ip NVR IP |
|
|
|
* @return 缓存中存在会话时返回 true |
|
|
|
*/ |
|
|
|
public boolean isLoggedIn(String ip) { |
|
|
|
return sessionCache.getIfPresent(ip) != null; |
|
|
|
// 空 IP 不可能命中登录缓存。 |
|
|
|
if (!hasText(ip)) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
return sessionCache.getIfPresent(ip.trim()) != null; |
|
|
|
} |
|
|
|
|
|
|
|
/** 判断文本是否包含非空白内容。 */ |
|
|
|
private boolean hasText(String value) { |
|
|
|
// 同时覆盖 null、空串和纯空白输入。 |
|
|
|
return value != null && !value.trim().isEmpty(); |
|
|
|
} |
|
|
|
} |