liujie
2026-08-11 323764c813a09cd56cdab780155ef659dbe0b364
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
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;
        }
    }
}