package com.ruoyi.web.controller.tool; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import okhttp3.Credentials; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * 海康威视智能身份识别终端 DS-K1T6UVSCHT-NX 门禁人员、卡、人脸下发工具。 */ public final class HikvisionAccessControlApi { private static final String USER_INFO_SET_UP_PATH = "/ISAPI/AccessControl/UserInfo/SetUp?format=json"; private static final String CARD_INFO_SET_UP_PATH = "/ISAPI/AccessControl/CardInfo/SetUp?format=json"; private static final String FACE_DATA_RECORD_PATH = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json"; private static final String USER_INFO_DELETE_PATH = "/ISAPI/AccessControl/UserInfo/Delete?format=json"; private static final String CARD_INFO_DELETE_PATH = "/ISAPI/AccessControl/CardInfo/Delete?format=json"; private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8"); private static final MediaType JPEG_MEDIA_TYPE = MediaType.parse("image/jpeg"); private static final MediaType PNG_MEDIA_TYPE = MediaType.parse("image/png"); private static final OkHttpClient CLIENT = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .writeTimeout(20, TimeUnit.SECONDS) .build(); private HikvisionAccessControlApi() { } /** * 下发员工基础信息,并按员工数据绑定卡号和人脸。 */ public static IssueResult issueEmployee(DeviceConfig device, EmployeeCredential employee) throws IOException { Objects.requireNonNull(employee, "employee"); IssueResult result = new IssueResult(); HikvisionResult userInfo = setupUserInfo(device, employee); result.setUserInfo(userInfo); ensureSuccess(userInfo, "下发人员信息失败"); if (isNotBlank(employee.getCardNo())) { HikvisionResult card = bindCard(device, employee.getEmployeeNo(), employee.getCardNo()); result.setCard(card); ensureSuccess(card, "绑定员工卡号失败"); } byte[] faceImage = employee.resolveFaceImage(); if (faceImage != null && faceImage.length > 0) { HikvisionResult face = bindFace(device, employee.getEmployeeNo(), faceImage); result.setFace(face); ensureSuccess(face, "绑定员工人脸失败"); } return result; } /** * 删除门禁人员,并尽量清理绑定卡和人脸凭据。 */ public static DeleteResult deleteEmployee(DeviceConfig device, EmployeeCredential employee) throws IOException { Objects.requireNonNull(employee, "employee"); requireText(employee.getEmployeeNo(), "employeeNo"); DeleteResult result = new DeleteResult(); if (isNotBlank(employee.getCardNo())) { result.setCard(deleteCard(device, employee.getCardNo())); } result.setFace(deleteFace(device, employee.getEmployeeNo())); HikvisionResult userInfo = deleteUserInfo(device, employee.getEmployeeNo()); result.setUserInfo(userInfo); ensureSuccess(userInfo, "delete user info failed"); return result; } /** * 下发或更新人员基础信息。DS-K1T6UVSCHT-NX 单门场景默认 doorRight=1、doorNo=1、planTemplateNo=1。 */ public static HikvisionResult setupUserInfo(DeviceConfig device, EmployeeCredential employee) throws IOException { requireDevice(device); requireText(employee == null ? null : employee.getEmployeeNo(), "employeeNo"); requireText(employee.getName(), "name"); JSONObject valid = new JSONObject(true); valid.put("enable", true); valid.put("beginTime", defaultIfBlank(employee.getBeginTime(), "2000-01-01T00:00:00")); valid.put("endTime", defaultIfBlank(employee.getEndTime(), "2037-12-31T23:59:59")); valid.put("timeType", "local"); JSONObject rightPlan = new JSONObject(true); rightPlan.put("doorNo", employee.getDoorNo()); rightPlan.put("planTemplateNo", defaultIfBlank(employee.getPlanTemplateNo(), "1")); JSONObject userInfo = new JSONObject(true); userInfo.put("employeeNo", employee.getEmployeeNo()); userInfo.put("name", employee.getName()); userInfo.put("userType", defaultIfBlank(employee.getUserType(), "normal")); userInfo.put("Valid", valid); userInfo.put("doorRight", defaultIfBlank(employee.getDoorRight(), "1")); userInfo.put("RightPlan", singletonList(rightPlan)); userInfo.put("gender", defaultIfBlank(employee.getGender(), "unknown")); userInfo.put("localUIRight", false); userInfo.put("maxOpenDoorTime", 0); JSONObject body = new JSONObject(true); body.put("UserInfo", userInfo); return executeJson(device, "PUT", USER_INFO_SET_UP_PATH, body); } /** * 绑定员工卡号。 */ public static HikvisionResult bindCard(DeviceConfig device, String employeeNo, String cardNo) throws IOException { requireDevice(device); requireText(employeeNo, "employeeNo"); requireText(cardNo, "cardNo"); JSONObject cardInfo = new JSONObject(true); cardInfo.put("employeeNo", employeeNo); cardInfo.put("cardNo", cardNo); cardInfo.put("cardType", "normalCard"); cardInfo.put("leaderCard", false); cardInfo.put("checkCardNo", true); JSONObject body = new JSONObject(true); body.put("CardInfo", cardInfo); return executeJson(device, "PUT", CARD_INFO_SET_UP_PATH, body); } /** * 绑定员工人脸,默认写入设备黑名单人脸库 blackFD/FDID=1,FPID 使用员工编号。 */ public static HikvisionResult bindFace(DeviceConfig device, String employeeNo, byte[] faceImage) throws IOException { requireDevice(device); requireText(employeeNo, "employeeNo"); if (faceImage == null || faceImage.length == 0) { throw new IllegalArgumentException("faceImage 不能为空"); } JSONObject faceData = new JSONObject(true); faceData.put("faceLibType", "blackFD"); faceData.put("FDID", "1"); faceData.put("FPID", employeeNo); RequestBody body = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("FaceDataRecord", "FaceDataRecord", RequestBody.create(JSON_MEDIA_TYPE, JSON.toJSONString(faceData))) .addFormDataPart("img", employeeNo + imageSuffix(faceImage), RequestBody.create(resolveImageMediaType(faceImage), faceImage)) .build(); return execute(device, new Request.Builder() .url(device.resolveUrl(FACE_DATA_RECORD_PATH)) .put(body) .build()); } /** * 删除设备端人员基础信息。 */ public static HikvisionResult deleteUserInfo(DeviceConfig device, String employeeNo) throws IOException { requireDevice(device); requireText(employeeNo, "employeeNo"); JSONObject employeeNoItem = new JSONObject(true); employeeNoItem.put("employeeNo", employeeNo); JSONObject userInfoDelCond = new JSONObject(true); userInfoDelCond.put("EmployeeNoList", singletonList(employeeNoItem)); JSONObject body = new JSONObject(true); body.put("UserInfoDelCond", userInfoDelCond); return executeJson(device, "DELETE", USER_INFO_DELETE_PATH, body); } public static HikvisionResult deleteCard(DeviceConfig device, String cardNo) throws IOException { requireDevice(device); requireText(cardNo, "cardNo"); JSONObject cardNoItem = new JSONObject(true); cardNoItem.put("cardNo", cardNo); JSONObject cardInfoDelCond = new JSONObject(true); cardInfoDelCond.put("CardNoList", singletonList(cardNoItem)); JSONObject body = new JSONObject(true); body.put("CardInfoDelCond", cardInfoDelCond); return executeJson(device, "DELETE", CARD_INFO_DELETE_PATH, body); } public static HikvisionResult deleteFace(DeviceConfig device, String employeeNo) throws IOException { requireDevice(device); requireText(employeeNo, "employeeNo"); JSONObject faceData = new JSONObject(true); faceData.put("faceLibType", "blackFD"); faceData.put("FDID", "1"); faceData.put("FPID", employeeNo); JSONObject body = new JSONObject(true); body.put("FaceDataRecord", faceData); return executeJson(device, "DELETE", FACE_DATA_RECORD_PATH, body); } /** * 通过图片 URL 下载人脸并绑定到员工。 */ public static HikvisionResult bindFaceByUrl(DeviceConfig device, String employeeNo, String faceImageUrl) throws IOException { requireText(faceImageUrl, "faceImageUrl"); return bindFace(device, employeeNo, downloadImage(faceImageUrl)); } /** * 通过 Base64 图片绑定员工人脸,兼容 data:image/jpeg;base64 前缀。 */ public static HikvisionResult bindFaceByBase64(DeviceConfig device, String employeeNo, String faceImageBase64) throws IOException { requireText(faceImageBase64, "faceImageBase64"); String value = faceImageBase64; int commaIndex = value.indexOf(','); if (commaIndex > -1) { value = value.substring(commaIndex + 1); } return bindFace(device, employeeNo, Base64.getDecoder().decode(value)); } private static HikvisionResult executeJson(DeviceConfig device, String method, String path, JSONObject body) throws IOException { RequestBody requestBody = RequestBody.create(JSON_MEDIA_TYPE, JSON.toJSONString(body)); Request.Builder builder = new Request.Builder() .url(device.resolveUrl(path)) .header("Accept", "application/json"); if ("POST".equals(method)) { builder.post(requestBody); } else if ("DELETE".equals(method)) { builder.delete(requestBody); } else { builder.put(requestBody); } return execute(device, builder.build()); } private static HikvisionResult execute(DeviceConfig device, Request request) throws IOException { Response response = CLIENT.newCall(request).execute(); int code = response.code(); String authenticate = response.header("WWW-Authenticate"); String body = readBody(response); if (code != 401) { return HikvisionResult.of(request.url().toString(), code, body); } if (isBlank(authenticate)) { return HikvisionResult.of(request.url().toString(), 401, body); } Request retryRequest = buildAuthenticatedRequest(device, request, authenticate); Response retryResponse = CLIENT.newCall(retryRequest).execute(); return HikvisionResult.of(retryRequest.url().toString(), retryResponse.code(), readBody(retryResponse)); } private static Request buildAuthenticatedRequest(DeviceConfig device, Request request, String authenticate) { String lowerAuthenticate = authenticate.toLowerCase(Locale.ROOT); if (lowerAuthenticate.startsWith("digest")) { return request.newBuilder() .header("Authorization", buildDigestAuthorization(device, request, authenticate)) .build(); } if (lowerAuthenticate.startsWith("basic")) { return request.newBuilder() .header("Authorization", Credentials.basic(device.getUsername(), device.getPassword(), StandardCharsets.UTF_8)) .build(); } return request; } private static String buildDigestAuthorization(DeviceConfig device, Request request, String authenticate) { Map challenge = parseDigestChallenge(authenticate); String realm = challenge.get("realm"); String nonce = challenge.get("nonce"); String qop = firstQop(challenge.get("qop")); String algorithm = defaultIfBlank(challenge.get("algorithm"), "MD5"); if (!"MD5".equalsIgnoreCase(algorithm)) { throw new IllegalArgumentException("不支持的海康 Digest algorithm: " + algorithm); } String uri = request.url().encodedPath() + (request.url().encodedQuery() == null ? "" : "?" + request.url().encodedQuery()); String nc = "00000001"; String cnonce = UUID.randomUUID().toString().replace("-", ""); String ha1 = md5(device.getUsername() + ":" + realm + ":" + device.getPassword()); String ha2 = md5(request.method() + ":" + uri); String response; if (isNotBlank(qop)) { response = md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2); } else { response = md5(ha1 + ":" + nonce + ":" + ha2); } List parts = new ArrayList<>(); parts.add("username=\"" + escapeDigestValue(device.getUsername()) + "\""); parts.add("realm=\"" + escapeDigestValue(realm) + "\""); parts.add("nonce=\"" + escapeDigestValue(nonce) + "\""); parts.add("uri=\"" + escapeDigestValue(uri) + "\""); parts.add("response=\"" + response + "\""); parts.add("algorithm=MD5"); if (isNotBlank(challenge.get("opaque"))) { parts.add("opaque=\"" + escapeDigestValue(challenge.get("opaque")) + "\""); } if (isNotBlank(qop)) { parts.add("qop=" + qop); parts.add("nc=" + nc); parts.add("cnonce=\"" + cnonce + "\""); } return "Digest " + join(parts); } private static Map parseDigestChallenge(String authenticate) { String value = authenticate.trim(); if (value.toLowerCase(Locale.ROOT).startsWith("digest")) { value = value.substring(6).trim(); } Map result = new LinkedHashMap<>(); int index = 0; while (index < value.length()) { while (index < value.length() && (value.charAt(index) == ',' || Character.isWhitespace(value.charAt(index)))) { index++; } int equalsIndex = value.indexOf('=', index); if (equalsIndex < 0) { break; } String key = value.substring(index, equalsIndex).trim(); index = equalsIndex + 1; String parsedValue; if (index < value.length() && value.charAt(index) == '"') { StringBuilder builder = new StringBuilder(); index++; while (index < value.length()) { char ch = value.charAt(index++); if (ch == '\\' && index < value.length()) { builder.append(value.charAt(index++)); } else if (ch == '"') { break; } else { builder.append(ch); } } parsedValue = builder.toString(); } else { int commaIndex = value.indexOf(',', index); if (commaIndex < 0) { parsedValue = value.substring(index).trim(); index = value.length(); } else { parsedValue = value.substring(index, commaIndex).trim(); index = commaIndex + 1; } } if (isNotBlank(key)) { result.put(key, parsedValue); } } return result; } private static byte[] downloadImage(String url) throws IOException { Request request = new Request.Builder().url(url).get().build(); Response response = CLIENT.newCall(request).execute(); try { if (!response.isSuccessful()) { throw new IOException("下载人脸图片失败,HTTP " + response.code()); } ResponseBody body = response.body(); if (body == null) { throw new IOException("下载人脸图片失败,响应为空"); } return body.bytes(); } finally { response.close(); } } private static void ensureSuccess(HikvisionResult result, String message) throws IOException { if (result == null || !result.isSuccess()) { throw new IOException(message + ",设备响应:" + (result == null ? "" : result.getBody())); } } private static void requireDevice(DeviceConfig device) { Objects.requireNonNull(device, "device"); requireText(device.getUsername(), "username"); requireText(device.getPassword(), "password"); if (isBlank(device.getBaseUrl()) && isBlank(device.getHost())) { throw new IllegalArgumentException("baseUrl 或 host 不能为空"); } } private static void requireText(String value, String fieldName) { if (isBlank(value)) { throw new IllegalArgumentException(fieldName + " 不能为空"); } } private static String readBody(Response response) throws IOException { try { ResponseBody body = response.body(); return body == null ? "" : body.string(); } finally { response.close(); } } private static String firstQop(String qop) { if (isBlank(qop)) { return ""; } String[] values = qop.split(","); for (String value : values) { if ("auth".equals(value.trim())) { return "auth"; } } return values[0].trim(); } private static String md5(String value) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] bytes = digest.digest(value.getBytes(StandardCharsets.ISO_8859_1)); StringBuilder builder = new StringBuilder(bytes.length * 2); for (byte b : bytes) { String hex = Integer.toHexString(b & 0xff); if (hex.length() == 1) { builder.append('0'); } builder.append(hex); } return builder.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("当前 JDK 不支持 MD5", e); } } private static String escapeDigestValue(String value) { return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); } private static MediaType resolveImageMediaType(byte[] bytes) { return isPng(bytes) ? PNG_MEDIA_TYPE : JPEG_MEDIA_TYPE; } private static String imageSuffix(byte[] bytes) { return isPng(bytes) ? ".png" : ".jpg"; } private static boolean isPng(byte[] bytes) { return bytes.length > 4 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47; } private static String join(List values) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < values.size(); i++) { if (i > 0) { builder.append(", "); } builder.append(values.get(i)); } return builder.toString(); } private static String defaultIfBlank(String value, String defaultValue) { return isBlank(value) ? defaultValue : value; } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } private static boolean isNotBlank(String value) { return !isBlank(value); } private static List singletonList(JSONObject value) { List list = new ArrayList<>(1); list.add(value); return list; } public static final class DeviceConfig { private String baseUrl; private String host; private int port = 80; private boolean https; private String username; private String password; public static DeviceConfig http(String host, String username, String password) { DeviceConfig config = new DeviceConfig(); config.setHost(host); config.setUsername(username); config.setPassword(password); return config; } public String resolveUrl(String path) { String base = isNotBlank(baseUrl) ? baseUrl.trim() : buildBaseUrl(); while (base.endsWith("/")) { base = base.substring(0, base.length() - 1); } return base + path; } private String buildBaseUrl() { String value = host.trim(); if (value.startsWith("http://") || value.startsWith("https://")) { return value; } String scheme = https ? "https://" : "http://"; boolean appendPort = port > 0 && !hasPort(value); return scheme + value + (appendPort ? ":" + port : ""); } private boolean hasPort(String value) { int colonIndex = value.lastIndexOf(':'); return colonIndex > -1 && colonIndex < value.length() - 1 && value.indexOf(']') < colonIndex; } public String getBaseUrl() { return baseUrl; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public String getHost() { return host; } public void setHost(String host) { this.host = host; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } public boolean isHttps() { return https; } public void setHttps(boolean https) { this.https = https; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } public static final class EmployeeCredential { private String employeeNo; private String name; private String cardNo; private byte[] faceImage; private String faceImageUrl; private String faceImageBase64; private String beginTime; private String endTime; private String userType = "normal"; private String doorRight = "1"; private int doorNo = 1; private String planTemplateNo = "1"; private String gender = "unknown"; public static EmployeeCredential of(String employeeNo, String name, String cardNo) { EmployeeCredential credential = new EmployeeCredential(); credential.setEmployeeNo(employeeNo); credential.setName(name); credential.setCardNo(cardNo); return credential; } private byte[] resolveFaceImage() throws IOException { if (faceImage != null && faceImage.length > 0) { return faceImage; } if (isNotBlank(faceImageBase64)) { String value = faceImageBase64; int commaIndex = value.indexOf(','); if (commaIndex > -1) { value = value.substring(commaIndex + 1); } return Base64.getDecoder().decode(value); } if (isNotBlank(faceImageUrl)) { return downloadImage(faceImageUrl); } return null; } public String getEmployeeNo() { return employeeNo; } public void setEmployeeNo(String employeeNo) { this.employeeNo = employeeNo; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getCardNo() { return cardNo; } public void setCardNo(String cardNo) { this.cardNo = cardNo; } public byte[] getFaceImage() { return faceImage; } public void setFaceImage(byte[] faceImage) { this.faceImage = faceImage; } public String getFaceImageUrl() { return faceImageUrl; } public void setFaceImageUrl(String faceImageUrl) { this.faceImageUrl = faceImageUrl; } public String getFaceImageBase64() { return faceImageBase64; } public void setFaceImageBase64(String faceImageBase64) { this.faceImageBase64 = faceImageBase64; } public String getBeginTime() { return beginTime; } public void setBeginTime(String beginTime) { this.beginTime = beginTime; } public String getEndTime() { return endTime; } public void setEndTime(String endTime) { this.endTime = endTime; } public String getUserType() { return userType; } public void setUserType(String userType) { this.userType = userType; } public String getDoorRight() { return doorRight; } public void setDoorRight(String doorRight) { this.doorRight = doorRight; } public int getDoorNo() { return doorNo; } public void setDoorNo(int doorNo) { this.doorNo = doorNo; } public String getPlanTemplateNo() { return planTemplateNo; } public void setPlanTemplateNo(String planTemplateNo) { this.planTemplateNo = planTemplateNo; } public String getGender() { return gender; } public void setGender(String gender) { this.gender = gender; } } public static final class DeleteResult { private HikvisionResult userInfo; private HikvisionResult card; private HikvisionResult face; public HikvisionResult getUserInfo() { return userInfo; } public void setUserInfo(HikvisionResult userInfo) { this.userInfo = userInfo; } public HikvisionResult getCard() { return card; } public void setCard(HikvisionResult card) { this.card = card; } public HikvisionResult getFace() { return face; } public void setFace(HikvisionResult face) { this.face = face; } } public static final class IssueResult { private HikvisionResult userInfo; private HikvisionResult card; private HikvisionResult face; public HikvisionResult getUserInfo() { return userInfo; } public void setUserInfo(HikvisionResult userInfo) { this.userInfo = userInfo; } public HikvisionResult getCard() { return card; } public void setCard(HikvisionResult card) { this.card = card; } public HikvisionResult getFace() { return face; } public void setFace(HikvisionResult face) { this.face = face; } } public static final class HikvisionResult { private String url; private int httpCode; private String body; private static HikvisionResult of(String url, int httpCode, String body) { HikvisionResult result = new HikvisionResult(); result.setUrl(url); result.setHttpCode(httpCode); result.setBody(body); return result; } public boolean isSuccess() { if (httpCode < 200 || httpCode >= 300) { return false; } if (isBlank(body)) { return true; } try { JSONObject root = JSON.parseObject(body); Object statusCode = root.get("statusCode"); if (statusCode == null && root.getJSONObject("ResponseStatus") != null) { statusCode = root.getJSONObject("ResponseStatus").get("statusCode"); } if (statusCode == null) { return true; } if (statusCode instanceof Number) { return ((Number) statusCode).intValue() == 1; } String value = String.valueOf(statusCode); return "1".equals(value) || "OK".equalsIgnoreCase(value) || "ok".equalsIgnoreCase(value); } catch (Exception e) { return true; } } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public int getHttpCode() { return httpCode; } public void setHttpCode(int httpCode) { this.httpCode = httpCode; } public String getBody() { return body; } public void setBody(String body) { this.body = body; } } }