package com.ruoyi.web.integration.dahua;
|
|
import com.ruoyi.common.utils.StringUtils;
|
import com.sun.jna.Memory;
|
import com.sun.jna.Native;
|
import com.sun.jna.NativeLibrary;
|
import com.sun.jna.Pointer;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Component;
|
|
import javax.annotation.PreDestroy;
|
import java.io.File;
|
import java.nio.charset.Charset;
|
import java.nio.charset.StandardCharsets;
|
import java.util.Calendar;
|
import java.util.Date;
|
|
/**
|
* 大华门禁 NetSDK 客户端。
|
*/
|
@Slf4j
|
@Component
|
public class DahuaAccessControlClient {
|
|
private final DahuaAccessProperties properties;
|
private final Object sdkLock = new Object();
|
|
private volatile DahuaNetSdk sdk;
|
|
public DahuaAccessControlClient(DahuaAccessProperties properties) {
|
this.properties = properties;
|
}
|
|
/**
|
* 在单台设备上新增或更新访客人员,并下发一张人脸照片。
|
*/
|
public void grant(DahuaDeviceCredential device, VisitorCredential visitor) {
|
requireVisitor(visitor, true);
|
requireDevice(device);
|
execute(device, (loadedSdk, loginHandle) -> {
|
insertUser(loadedSdk, loginHandle, device, visitor);
|
try {
|
insertFace(loadedSdk, loginHandle, visitor.getUserId(), visitor.getFaceImage());
|
} catch (RuntimeException e) {
|
try {
|
removeUser(loadedSdk, loginHandle, visitor.getUserId(), true);
|
} catch (RuntimeException rollbackException) {
|
e.addSuppressed(rollbackException);
|
}
|
throw e;
|
}
|
});
|
}
|
|
/**
|
* 从单台设备撤销访客人脸及人员权限。
|
*/
|
public void revoke(DahuaDeviceCredential device, String userId) {
|
requireText(userId, "访客门禁用户ID不能为空");
|
requireDevice(device);
|
byte[] userIdBytes = encode(userId, StandardCharsets.UTF_8, DahuaSdkLayout.USER_ID_LENGTH,
|
"访客门禁用户ID");
|
execute(device, (loadedSdk, loginHandle) -> {
|
RuntimeException faceException = null;
|
try {
|
removeFace(loadedSdk, loginHandle, userIdBytes, true);
|
} catch (RuntimeException e) {
|
faceException = e;
|
}
|
|
try {
|
removeUser(loadedSdk, loginHandle, userIdBytes, true);
|
} catch (RuntimeException userException) {
|
if (faceException != null) {
|
userException.addSuppressed(faceException);
|
}
|
throw userException;
|
}
|
|
if (faceException != null) {
|
// 人员删除接口会清理该人员的全部授权;人员已删除时,人脸删除失败不再阻断撤权。
|
log.warn("大华门禁人员已删除,但单独删除人脸未成功,device={},userId={}",
|
device.getAddress(), userId, faceException);
|
}
|
});
|
}
|
|
private void execute(DahuaDeviceCredential device, DeviceOperation operation) {
|
DahuaDeviceAddress address = DahuaDeviceAddress.parse(device.getAddress(), device.getPort());
|
DahuaNetSdk loadedSdk = getSdk();
|
long loginHandle = login(loadedSdk, address, device);
|
try {
|
operation.execute(loadedSdk, loginHandle);
|
} finally {
|
if (!loadedSdk.CLIENT_Logout(loginHandle)) {
|
log.warn("大华门禁设备注销失败,device={},error={}", device.getAddress(), formatLastError(loadedSdk));
|
}
|
}
|
}
|
|
private long login(DahuaNetSdk loadedSdk, DahuaDeviceAddress address, DahuaDeviceCredential device) {
|
Memory input = memory(DahuaSdkLayout.LOGIN_INPUT_SIZE);
|
input.setInt(0, DahuaSdkLayout.LOGIN_INPUT_SIZE);
|
writeFixed(input, 4, 64, encode(address.getHost(), StandardCharsets.UTF_8, 64, "设备IP"));
|
input.setInt(68, address.getPort());
|
writeFixed(input, 72, 64, encode(device.getUsername(), StandardCharsets.UTF_8, 64, "设备用户名"));
|
writeFixed(input, 136, 64, encode(device.getPassword(), StandardCharsets.UTF_8, 64, "设备密码"));
|
|
Memory output = memory(DahuaSdkLayout.LOGIN_OUTPUT_SIZE);
|
output.setInt(0, DahuaSdkLayout.LOGIN_OUTPUT_SIZE);
|
|
long loginHandle = loadedSdk.CLIENT_LoginWithHighLevelSecurity(input, output);
|
if (loginHandle == 0) {
|
int loginError = output.getInt(DahuaSdkLayout.LOGIN_OUTPUT_ERROR_OFFSET);
|
throw new DahuaAccessException("登录大华门禁设备失败,loginError=" + loginError
|
+ ",sdkError=" + formatLastError(loadedSdk));
|
}
|
return loginHandle;
|
}
|
|
private void insertUser(DahuaNetSdk loadedSdk, long loginHandle,
|
DahuaDeviceCredential device, VisitorCredential visitor) {
|
byte[] userId = encode(visitor.getUserId(), StandardCharsets.UTF_8,
|
DahuaSdkLayout.USER_ID_LENGTH, "访客门禁用户ID");
|
Charset nameCharset = resolveCharset(device.getCharset());
|
byte[] name = encode(visitor.getName(), nameCharset,
|
DahuaSdkLayout.USER_NAME_LENGTH, "访客姓名");
|
byte[] citizenId = StringUtils.isEmpty(StringUtils.trim(visitor.getCitizenId()))
|
? new byte[0]
|
: encode(visitor.getCitizenId(), StandardCharsets.UTF_8,
|
DahuaSdkLayout.CITIZEN_ID_LENGTH, "访客证件号");
|
|
Memory userInfo = memory(DahuaSdkLayout.USER_INFO_SIZE);
|
writeFixed(userInfo, DahuaSdkLayout.USER_ID_OFFSET, DahuaSdkLayout.USER_ID_LENGTH, userId);
|
writeFixed(userInfo, DahuaSdkLayout.USER_NAME_OFFSET, DahuaSdkLayout.USER_NAME_LENGTH, name);
|
// 使用普通人员类型,由有效期控制访问窗口,避免来宾类型的通行次数限制。
|
userInfo.setInt(DahuaSdkLayout.USER_TYPE_OFFSET, 0);
|
userInfo.setInt(DahuaSdkLayout.USER_STATUS_OFFSET, 0);
|
userInfo.setInt(DahuaSdkLayout.USER_TIME_OFFSET, 0);
|
writeFixed(userInfo, DahuaSdkLayout.CITIZEN_ID_OFFSET,
|
DahuaSdkLayout.CITIZEN_ID_LENGTH, citizenId);
|
userInfo.setInt(DahuaSdkLayout.DOOR_NUM_OFFSET, 1);
|
userInfo.setInt(DahuaSdkLayout.DOORS_OFFSET, device.getDoorNo());
|
userInfo.setInt(DahuaSdkLayout.TIME_SECTION_NUM_OFFSET, 1);
|
userInfo.setInt(DahuaSdkLayout.TIME_SECTION_OFFSET, device.getTimeSectionNo());
|
writeTime(userInfo, DahuaSdkLayout.VALID_BEGIN_TIME_OFFSET, visitor.getValidFrom());
|
writeTime(userInfo, DahuaSdkLayout.VALID_END_TIME_OFFSET, visitor.getValidTo());
|
|
Memory input = simpleInput(DahuaSdkLayout.SIMPLE_INPUT_SIZE, 1, userInfo);
|
Memory output = simpleOutput(DahuaSdkLayout.SIMPLE_OUTPUT_SIZE);
|
boolean success = loadedSdk.CLIENT_OperateAccessUserService(loginHandle,
|
DahuaSdkLayout.USER_OPERATION_INSERT, input, output, properties.getTimeout());
|
assertOperationSuccess(loadedSdk, success, output, "新增访客人员", false);
|
}
|
|
private void insertFace(DahuaNetSdk loadedSdk, long loginHandle, String userIdValue, byte[] image) {
|
byte[] userId = encode(userIdValue, StandardCharsets.UTF_8,
|
DahuaSdkLayout.USER_ID_LENGTH, "访客门禁用户ID");
|
Memory imageMemory = memory(image.length);
|
imageMemory.write(0, image, 0, image.length);
|
|
Memory faceInfo = memory(DahuaSdkLayout.FACE_INFO_SIZE);
|
writeFixed(faceInfo, DahuaSdkLayout.USER_ID_OFFSET, DahuaSdkLayout.USER_ID_LENGTH, userId);
|
faceInfo.setInt(DahuaSdkLayout.FACE_PHOTO_NUM_OFFSET, 1);
|
faceInfo.setInt(DahuaSdkLayout.FACE_PHOTO_LENGTH_OFFSET, image.length);
|
faceInfo.setPointer(DahuaSdkLayout.FACE_PHOTO_POINTER_OFFSET, imageMemory);
|
|
Memory input = simpleInput(DahuaSdkLayout.SIMPLE_INPUT_SIZE, 1, faceInfo);
|
Memory output = simpleOutput(DahuaSdkLayout.FACE_INSERT_OUTPUT_SIZE);
|
boolean success = loadedSdk.CLIENT_OperateAccessFaceService(loginHandle,
|
DahuaSdkLayout.FACE_OPERATION_INSERT, input, output, properties.getTimeout());
|
int failCode = getFailCode(output);
|
int operation = DahuaSdkLayout.LEGACY_FACE_OPERATION_INSERT;
|
if (failCode == DahuaSdkLayout.FAIL_CODE_PHOTO_EXISTS
|
|| failCode == DahuaSdkLayout.FAIL_CODE_FACE_FEATURE_EXISTS) {
|
operation = DahuaSdkLayout.LEGACY_FACE_OPERATION_UPDATE;
|
output = simpleOutput(DahuaSdkLayout.FACE_INSERT_OUTPUT_SIZE);
|
success = loadedSdk.CLIENT_OperateAccessFaceService(loginHandle,
|
DahuaSdkLayout.FACE_OPERATION_UPDATE, input, output, properties.getTimeout());
|
}
|
if (!success && getFailCode(output) == DahuaSdkLayout.FAIL_CODE_NONE) {
|
// 部分 DH-ASI 固件对新接口返回通用失败,但旧版人脸接口仍可正常入库。
|
success = insertLegacyFace(loadedSdk, loginHandle, userId, image, operation);
|
}
|
assertOperationSuccess(loadedSdk, success, output, "新增访客人脸", false);
|
}
|
|
private boolean insertLegacyFace(DahuaNetSdk loadedSdk, long loginHandle,
|
byte[] userId, byte[] image, int operation) {
|
Memory imageMemory = memory(image.length);
|
imageMemory.write(0, image, 0, image.length);
|
|
Memory input = memory(DahuaSdkLayout.LEGACY_FACE_INPUT_SIZE);
|
input.setInt(0, DahuaSdkLayout.LEGACY_FACE_INPUT_SIZE);
|
writeFixed(input, DahuaSdkLayout.LEGACY_FACE_USER_ID_OFFSET,
|
DahuaSdkLayout.USER_ID_LENGTH, userId);
|
input.setInt(DahuaSdkLayout.LEGACY_FACE_PHOTO_NUM_OFFSET, 1);
|
input.setInt(DahuaSdkLayout.LEGACY_FACE_PHOTO_LENGTH_OFFSET, image.length);
|
input.setPointer(DahuaSdkLayout.LEGACY_FACE_PHOTO_POINTER_OFFSET, imageMemory);
|
|
Memory output = memory(DahuaSdkLayout.LEGACY_FACE_OUTPUT_SIZE);
|
output.setInt(0, DahuaSdkLayout.LEGACY_FACE_OUTPUT_SIZE);
|
return loadedSdk.CLIENT_FaceInfoOpreate(loginHandle, operation, input, output,
|
properties.getTimeout());
|
}
|
|
private void removeFace(DahuaNetSdk loadedSdk, long loginHandle,
|
byte[] userId, boolean allowMissing) {
|
Memory input = removeInput(userId);
|
Memory output = simpleOutput(DahuaSdkLayout.SIMPLE_OUTPUT_SIZE);
|
boolean success = loadedSdk.CLIENT_OperateAccessFaceService(loginHandle,
|
DahuaSdkLayout.FACE_OPERATION_REMOVE, input, output, properties.getTimeout());
|
assertOperationSuccess(loadedSdk, success, output, "删除访客人脸", allowMissing);
|
}
|
|
private void removeUser(DahuaNetSdk loadedSdk, long loginHandle,
|
String userId, boolean allowMissing) {
|
removeUser(loadedSdk, loginHandle,
|
encode(userId, StandardCharsets.UTF_8, DahuaSdkLayout.USER_ID_LENGTH, "访客门禁用户ID"),
|
allowMissing);
|
}
|
|
private void removeUser(DahuaNetSdk loadedSdk, long loginHandle,
|
byte[] userId, boolean allowMissing) {
|
Memory input = removeInput(userId);
|
Memory output = simpleOutput(DahuaSdkLayout.SIMPLE_OUTPUT_SIZE);
|
boolean success = loadedSdk.CLIENT_OperateAccessUserService(loginHandle,
|
DahuaSdkLayout.USER_OPERATION_REMOVE, input, output, properties.getTimeout());
|
assertOperationSuccess(loadedSdk, success, output, "删除访客人员", allowMissing);
|
}
|
|
private Memory simpleInput(int size, int count, Pointer data) {
|
Memory input = memory(size);
|
input.setInt(0, size);
|
input.setInt(4, count);
|
input.setPointer(8, data);
|
return input;
|
}
|
|
private Memory simpleOutput(int size) {
|
Memory output = memory(size + Integer.BYTES);
|
output.setInt(0, size);
|
output.setInt(4, 1);
|
output.setPointer(8, output.share(size));
|
return output;
|
}
|
|
private Memory removeInput(byte[] userId) {
|
Memory input = memory(DahuaSdkLayout.REMOVE_INPUT_SIZE);
|
input.setInt(0, DahuaSdkLayout.REMOVE_INPUT_SIZE);
|
input.setInt(4, 1);
|
writeFixed(input, DahuaSdkLayout.REMOVE_USER_IDS_OFFSET,
|
DahuaSdkLayout.USER_ID_LENGTH, userId);
|
return input;
|
}
|
|
private void assertOperationSuccess(DahuaNetSdk loadedSdk, boolean apiSuccess,
|
Memory output, String operation, boolean allowMissing) {
|
int failCode = getFailCode(output);
|
if (apiSuccess && (failCode == DahuaSdkLayout.FAIL_CODE_NONE
|
|| isMissingRecord(failCode, allowMissing))) {
|
return;
|
}
|
throw new DahuaAccessException(operation + "失败,failCode=" + failCode
|
+ ",sdkError=" + formatLastError(loadedSdk));
|
}
|
|
private boolean isMissingRecord(int failCode, boolean allowMissing) {
|
if (allowMissing && (failCode == DahuaSdkLayout.FAIL_CODE_NO_RECORD
|
|| failCode == DahuaSdkLayout.FAIL_CODE_INVALID_USER_ID)) {
|
return true;
|
}
|
return false;
|
}
|
|
private int getFailCode(Memory output) {
|
Pointer failCodePointer = output.getPointer(8);
|
return failCodePointer == null ? -1 : failCodePointer.getInt(0);
|
}
|
|
private DahuaNetSdk getSdk() {
|
DahuaNetSdk current = sdk;
|
if (current != null) {
|
return current;
|
}
|
synchronized (sdkLock) {
|
if (sdk != null) {
|
return sdk;
|
}
|
if (Native.POINTER_SIZE != 8) {
|
throw new DahuaAccessException("当前大华 NetSDK 绑定仅支持64位JVM和64位原生库");
|
}
|
requireText(properties.getNativeLibraryPath(), "未配置大华 NetSDK 原生库路径");
|
String library = resolveLibrary(properties.getNativeLibraryPath());
|
try {
|
DahuaNetSdk loaded = Native.load(library, DahuaNetSdk.class);
|
if (!loaded.CLIENT_Init(null, null)) {
|
throw new DahuaAccessException("初始化大华 NetSDK 失败,error=" + formatLastError(loaded));
|
}
|
loaded.CLIENT_SetConnectTime(properties.getConnectTimeout(), 1);
|
sdk = loaded;
|
log.info("大华 NetSDK 初始化成功,library={}", library);
|
return loaded;
|
} catch (DahuaAccessException e) {
|
throw e;
|
} catch (Throwable e) {
|
throw new DahuaAccessException("加载大华 NetSDK 失败:" + library, e);
|
}
|
}
|
}
|
|
private String resolveLibrary(String configuredPath) {
|
File configured = new File(StringUtils.trim(configuredPath));
|
if (configured.isFile()) {
|
return configured.getAbsolutePath();
|
}
|
if (!configured.isDirectory()) {
|
throw new DahuaAccessException("大华 NetSDK 原生库路径不存在:" + configured.getAbsolutePath());
|
}
|
NativeLibrary.addSearchPath("dhnetsdk", configured.getAbsolutePath());
|
String libraryName = isWindows() ? "dhnetsdk.dll" : "libdhnetsdk.so";
|
File library = new File(configured, libraryName);
|
if (!library.isFile()) {
|
throw new DahuaAccessException("大华 NetSDK 主库不存在:" + library.getAbsolutePath());
|
}
|
return library.getAbsolutePath();
|
}
|
|
private boolean isWindows() {
|
return System.getProperty("os.name", "").toLowerCase().contains("win");
|
}
|
|
private Charset resolveCharset(String charset) {
|
try {
|
return Charset.forName(charset);
|
} catch (Exception e) {
|
throw new DahuaAccessException("大华门禁人员姓名字符集配置无效:" + charset, e);
|
}
|
}
|
|
private void requireVisitor(VisitorCredential visitor, boolean requireFace) {
|
if (visitor == null) {
|
throw new DahuaAccessException("访客门禁凭据不能为空");
|
}
|
requireText(visitor.getUserId(), "访客门禁用户ID不能为空");
|
requireText(visitor.getName(), "访客姓名不能为空");
|
if (visitor.getValidFrom() == null || visitor.getValidTo() == null) {
|
throw new DahuaAccessException("访客门禁权限有效期不能为空");
|
}
|
if (!visitor.getValidFrom().before(visitor.getValidTo())) {
|
throw new DahuaAccessException("访客门禁权限结束时间必须晚于开始时间");
|
}
|
if (requireFace && (visitor.getFaceImage() == null || visitor.getFaceImage().length == 0)) {
|
throw new DahuaAccessException("访客人脸图片不能为空");
|
}
|
if (requireFace && visitor.getFaceImage().length > DahuaSdkLayout.MAX_FACE_IMAGE_BYTES) {
|
throw new DahuaAccessException("访客人脸图片不能超过200KB");
|
}
|
}
|
|
private void requireDevice(DahuaDeviceCredential device) {
|
if (device == null) {
|
throw new DahuaAccessException("大华门禁设备配置不能为空");
|
}
|
requireText(device.getAddress(), "大华门禁设备地址不能为空");
|
requireText(device.getUsername(), "大华门禁设备用户名不能为空");
|
requireText(device.getPassword(), "大华门禁设备密码不能为空");
|
requireText(device.getCharset(), "大华门禁设备姓名编码不能为空");
|
if (device.getPort() <= 0 || device.getPort() > 65535) {
|
throw new DahuaAccessException("大华门禁设备端口无效");
|
}
|
if (device.getDoorNo() < 0) {
|
throw new DahuaAccessException("大华门禁门通道序号不能小于0");
|
}
|
if (device.getTimeSectionNo() < 0 || device.getTimeSectionNo() > 255) {
|
throw new DahuaAccessException("大华门禁时间模板序号无效");
|
}
|
}
|
|
private void requireText(String value, String message) {
|
if (StringUtils.isEmpty(StringUtils.trim(value))) {
|
throw new DahuaAccessException(message);
|
}
|
}
|
|
private byte[] encode(String value, Charset charset, int capacity, String fieldName) {
|
requireText(value, fieldName + "不能为空");
|
byte[] bytes = StringUtils.trim(value).getBytes(charset);
|
if (bytes.length >= capacity) {
|
throw new DahuaAccessException(fieldName + "编码后长度必须小于" + capacity + "字节");
|
}
|
return bytes;
|
}
|
|
private void writeFixed(Memory memory, long offset, int capacity, byte[] value) {
|
if (value.length > capacity) {
|
throw new DahuaAccessException("写入大华 NetSDK 字段时数据越界");
|
}
|
if (value.length > 0) {
|
memory.write(offset, value, 0, value.length);
|
}
|
}
|
|
private void writeTime(Memory memory, long offset, Date value) {
|
Calendar calendar = Calendar.getInstance();
|
calendar.setTime(value);
|
memory.setInt(offset, calendar.get(Calendar.YEAR));
|
memory.setInt(offset + 4, calendar.get(Calendar.MONTH) + 1);
|
memory.setInt(offset + 8, calendar.get(Calendar.DAY_OF_MONTH));
|
memory.setInt(offset + 12, calendar.get(Calendar.HOUR_OF_DAY));
|
memory.setInt(offset + 16, calendar.get(Calendar.MINUTE));
|
memory.setInt(offset + 20, calendar.get(Calendar.SECOND));
|
}
|
|
private Memory memory(long size) {
|
Memory memory = new Memory(size);
|
memory.clear();
|
return memory;
|
}
|
|
private String formatLastError(DahuaNetSdk loadedSdk) {
|
return String.format("0x%08X", loadedSdk.CLIENT_GetLastError());
|
}
|
|
@PreDestroy
|
public void cleanup() {
|
synchronized (sdkLock) {
|
if (sdk != null) {
|
sdk.CLIENT_Cleanup();
|
sdk = null;
|
log.info("大华 NetSDK 资源已释放");
|
}
|
}
|
}
|
|
@FunctionalInterface
|
private interface DeviceOperation {
|
void execute(DahuaNetSdk sdk, long loginHandle);
|
}
|
|
/**
|
* 下发到设备的访客凭据。
|
*/
|
public static final class VisitorCredential {
|
private final String userId;
|
private final String name;
|
private final String citizenId;
|
private final Date validFrom;
|
private final Date validTo;
|
private final byte[] faceImage;
|
|
public VisitorCredential(String userId, String name, String citizenId,
|
Date validFrom, Date validTo, byte[] faceImage) {
|
this.userId = userId;
|
this.name = name;
|
this.citizenId = citizenId;
|
this.validFrom = validFrom;
|
this.validTo = validTo;
|
this.faceImage = faceImage;
|
}
|
|
public String getUserId() {
|
return userId;
|
}
|
|
public String getName() {
|
return name;
|
}
|
|
public String getCitizenId() {
|
return citizenId;
|
}
|
|
public Date getValidFrom() {
|
return validFrom;
|
}
|
|
public Date getValidTo() {
|
return validTo;
|
}
|
|
public byte[] getFaceImage() {
|
return faceImage;
|
}
|
}
|
}
|