diff --git a/src/main/java/com/sipai/controller/administration/AttendanceController.java b/src/main/java/com/sipai/controller/administration/AttendanceController.java index 1993ad8d..76f70787 100644 --- a/src/main/java/com/sipai/controller/administration/AttendanceController.java +++ b/src/main/java/com/sipai/controller/administration/AttendanceController.java @@ -1,7 +1,11 @@ package com.sipai.controller.administration; +import com.sipai.tools.HttpUtil; +import com.sipai.tools.DeviceAccessHttpUtil; import net.sf.json.JSONArray; import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; @@ -9,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; +import java.io.InputStream; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -17,22 +22,19 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -//curl --location 'http://127.0.0.1:8090/dh-netsdk/attendance/getDailyRecordList' \ -// header 'Content-Type: application/json' \ -// header 'Dh-Device-Ip: 192.168.1.108' \ -// header 'Dh-Device-User: admin' \ -// header 'Dh-Device-Port: 37777' \ -// header 'Dh-Device-Timestamp: {{dhTs}}' \ -// header 'Dh-Device-Password: {{dhEncPwd}}' \ -// data '{ -// "day": "2026-04-28", -// "pageNum": 1, -// "pageSize": 20 -// }' +import java.util.Properties; +import java.util.concurrent.atomic.AtomicLong; + @Controller @RequestMapping("/administration/attendance") public class AttendanceController { + private static final Logger LOG = LoggerFactory.getLogger(AttendanceController.class); private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final int DEFAULT_FETCH_DAYS = 30; + private static final int DEFAULT_REMOTE_PAGE_SIZE = 200; + private static final int MAX_REMOTE_PAGES_PER_DAY = 100; + private static final String DEFAULT_API_URL = "http://127.0.0.1:8090/dh-netsdk/attendance/getDailyRecordList"; + private static final Properties ATTENDANCE_PROPS = loadAttendanceProps(); @RequestMapping("/showList.do") public String showList(HttpServletRequest request, Model model) { @@ -53,7 +55,8 @@ public class AttendanceController { LocalDate startDate = parseDay(start); LocalDate endDate = parseDay(end); - List> allRows = buildMockRows(); + LocalDate[] range = resolveRange(startDate, endDate); + List> allRows = loadRowsFromRemote(request, range[0], range[1]); List> filtered = new ArrayList>(); for (Map item : allRows) { @@ -75,10 +78,10 @@ public class AttendanceController { if (!status.isEmpty() && !status.equals(st)) { continue; } - if (startDate != null && day != null && day.isBefore(startDate)) { + if (startDate != null && (day == null || day.isBefore(startDate))) { continue; } - if (endDate != null && day != null && day.isAfter(endDate)) { + if (endDate != null && (day == null || day.isAfter(endDate))) { continue; } filtered.add(item); @@ -97,6 +100,239 @@ public class AttendanceController { return new ModelAndView("result"); } + private List> loadRowsFromRemote(HttpServletRequest request, LocalDate startDate, LocalDate endDate) { + List> rows = new ArrayList>(); + if (startDate == null || endDate == null) { + return rows; + } + int pageSize = getIntProp("attendance.api.pageSize", DEFAULT_REMOTE_PAGE_SIZE); + String url = getProp("attendance.api.url", DEFAULT_API_URL); + Map headers = buildDhHeaders(request); + + LocalDate day = startDate; + while (!day.isAfter(endDate)) { + for (int pageNum = 1; pageNum <= MAX_REMOTE_PAGES_PER_DAY; pageNum++) { + com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(); + req.put("day", DAY_FMT.format(day)); + req.put("pageNum", pageNum); + req.put("pageSize", pageSize); + + String respText; + try { + respText = HttpUtil.sendPost(url, req, headers); + } catch (Exception ex) { + LOG.error("Attendance request failed, day={}, pageNum={}", day, pageNum, ex); + break; + } + + int fetched = appendRowsFromResponse(rows, respText, day, pageNum); + if (fetched <= 0 || fetched < pageSize) { + break; + } + } + day = day.plusDays(1); + } + return rows; + } + + private int appendRowsFromResponse(List> rows, String respText, LocalDate day, int pageNum) { + if (respText == null || respText.trim().isEmpty()) { + LOG.warn("Attendance response empty, day={}, pageNum={}", day, pageNum); + return 0; + } + JSONObject resp; + try { + resp = JSONObject.fromObject(respText); + } catch (Exception ex) { + LOG.error("Attendance response parse error, day={}, pageNum={}, body={}", day, pageNum, shorten(respText), ex); + return 0; + } + boolean success = "true".equalsIgnoreCase(String.valueOf(resp.opt("success"))); + if (!success) { + LOG.warn("Attendance response unsuccessful, day={}, pageNum={}, message={}", day, pageNum, resp.optString("message")); + return 0; + } + JSONArray data = resp.optJSONArray("data"); + if (data == null || data.isEmpty()) { + return 0; + } + for (int i = 0; i < data.size(); i++) { + Object item = data.get(i); + JSONObject dto = item instanceof JSONObject ? (JSONObject) item : JSONObject.fromObject(item); + rows.add(mapRemoteRecord(dto)); + } + return data.size(); + } + + private static Map buildDhHeaders(HttpServletRequest request) { + String ip = getProp("attendance.api.deviceIp", "192.168.1.108"); + String user = getProp("attendance.api.deviceUser", "admin"); + String port = getProp("attendance.api.devicePort", "37777"); +// String ts = safe(request.getParameter("dhTs")); +// if (ts.isEmpty()) { +// ts = String.valueOf(System.nanoTime()); +// } + String ts = nextUniqueTimestamp(); + + String plainPwd = getProp("attendance.api.devicePassword", ""); + if (plainPwd.isEmpty()) { + LOG.warn("attendance.api.devicePassword is empty, DH attendance call may fail auth"); + } + + Map headers; + try { + headers = DeviceAccessHttpUtil.buildDeviceHeaders(ip, user, plainPwd, ts, port); + } catch (Exception ex) { + throw new IllegalStateException("Build DH device headers failed", ex); + } + headers.put("Content-Type", "application/json"); + return headers; + } + + private static Map mapRemoteRecord(JSONObject dto) { + String eventTime = dto.optString("eventTime"); + String attendanceDate = extractDate(eventTime); + String checkInTime = extractTime(eventTime); + + Map row = new LinkedHashMap(); + row.put("id", buildRowId(dto, attendanceDate, checkInTime)); + row.put("employeeNo", firstNonEmpty(dto.optString("userId"), dto.optString("cardNo"))); + row.put("employeeName", firstNonEmpty(dto.optString("userName"), dto.optString("userId"), "--")); + row.put("deptName", firstNonEmpty(dto.optString("deptName"), "--")); + row.put("attendanceDate", attendanceDate); + row.put("checkInTime", checkInTime); + row.put("checkOutTime", "--"); + row.put("workHours", "--"); + row.put("status", resolveStatus(dto)); + row.put("source", "第三方接口(DH)"); + + // Keep raw fields for troubleshooting and later expansion. + row.put("cardNo", dto.optString("cardNo")); + row.put("channel", dto.optInt("channel")); + row.put("openMethod", dto.optInt("openMethod")); + row.put("result", dto.optInt("result")); + row.put("errCode", dto.optInt("errCode")); + row.put("attendanceState", dto.optInt("attendanceState")); + row.put("attendanceStateText", dto.optString("attendanceStateText")); + return row; + } + + private static String resolveStatus(JSONObject dto) { + String text = safe(dto.optString("attendanceStateText")); + if (!text.isEmpty()) { + return text; + } + int state = dto.optInt("attendanceState", -1); + if (state == 0) { + return "正常"; + } + if (state == 1) { + return "迟到"; + } + if (state == 2) { + return "早退"; + } + if (state == 3) { + return "缺卡"; + } + return "未知"; + } + + private static String buildRowId(JSONObject dto, String date, String time) { + String uid = safe(dto.optString("userId")); + String idx = String.valueOf(dto.optInt("index", 0)); + return firstNonEmpty(uid, "U") + "-" + safe(date) + "-" + safe(time) + "-" + idx; + } + + private static String firstNonEmpty(String... values) { + if (values == null) { + return ""; + } + for (String v : values) { + if (v != null && !v.trim().isEmpty()) { + return v.trim(); + } + } + return ""; + } + + private static String extractDate(String eventTime) { + String v = safe(eventTime); + if (v.length() >= 10) { + return v.substring(0, 10); + } + return ""; + } + + private static String extractTime(String eventTime) { + String v = safe(eventTime); + if (v.contains("T") && v.length() >= 19) { + return v.substring(11, 19); + } + int blank = v.indexOf(' '); + if (blank >= 0 && v.length() >= blank + 9) { + return v.substring(blank + 1, blank + 9); + } + return v; + } + + private static LocalDate[] resolveRange(LocalDate startDate, LocalDate endDate) { + LocalDate now = LocalDate.now(); + LocalDate start = startDate; + LocalDate end = endDate; + if (start == null && end == null) { + // No date filter from UI: fetch only current day. + start = now; + end = now; + } else if (start == null) { + // Only end date provided: query that single day. + start = end; + } else if (end == null) { + // Only start date provided: query that single day. + end = start; + } + if (start.isAfter(end)) { + LocalDate t = start; + start = end; + end = t; + } + return new LocalDate[]{start, end}; + } + + private static Properties loadAttendanceProps() { + Properties p = new Properties(); + try (InputStream in = AttendanceController.class.getClassLoader().getResourceAsStream("thirdRequest.properties")) { + if (in != null) { + p.load(in); + } + } catch (Exception ex) { + LOG.warn("Load thirdRequest.properties failed", ex); + } + return p; + } + + private static String getProp(String key, String defaultValue) { + String v = ATTENDANCE_PROPS.getProperty(key); + return v == null || v.trim().isEmpty() ? defaultValue : v.trim(); + } + + private static int getIntProp(String key, int defaultValue) { + String v = getProp(key, String.valueOf(defaultValue)); + try { + return Integer.parseInt(v); + } catch (Exception ex) { + return defaultValue; + } + } + + private static String shorten(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replace('\n', ' ').replace('\r', ' '); + return oneLine.length() > 400 ? oneLine.substring(0, 400) + "..." : oneLine; + } + private static void sortRows(List> rows, String sort, String order) { final boolean asc = "asc".equalsIgnoreCase(order); final String sortField = (sort == null || sort.trim().isEmpty() || "id".equals(sort)) ? "attendanceDate" : sort; @@ -128,61 +364,6 @@ public class AttendanceController { }); } - private static List> buildMockRows() { - String[][] employees = { - {"E0001", "张三", "生产部"}, - {"E0002", "李四", "设备部"}, - {"E0003", "王五", "品控部"}, - {"E0004", "赵六", "仓储部"}, - {"E0005", "钱七", "行政部"} - }; - - List> rows = new ArrayList>(); - LocalDate today = LocalDate.now(); - int idSeq = 1; - - for (int d = 0; d < 45; d++) { - LocalDate day = today.minusDays(d); - String dayStr = DAY_FMT.format(day); - for (int i = 0; i < employees.length; i++) { - String[] emp = employees[i]; - int flag = d + i; - - String status = "正常"; - String checkIn = "08:55"; - String checkOut = "18:05"; - String workHours = "8.5"; - - if (flag % 19 == 0) { - status = "缺卡"; - checkOut = "--"; - workHours = "4.2"; - } else if (flag % 11 == 0) { - status = "迟到"; - checkIn = "09:" + (10 + (flag % 20)); - workHours = "7.8"; - } else if (flag % 13 == 0) { - status = "早退"; - checkOut = "17:" + (20 + (flag % 30)); - workHours = "7.1"; - } - - Map row = new LinkedHashMap(); - row.put("id", "mock-" + idSeq++); - row.put("employeeNo", emp[0]); - row.put("employeeName", emp[1]); - row.put("deptName", emp[2]); - row.put("attendanceDate", dayStr); - row.put("checkInTime", checkIn); - row.put("checkOutTime", checkOut); - row.put("workHours", workHours); - row.put("status", status); - row.put("source", "第三方接口(MOCK)"); - rows.add(row); - } - } - return rows; - } private static String safe(String s) { return s == null ? "" : s.trim(); @@ -202,4 +383,17 @@ public class AttendanceController { return null; } } + + private static final AtomicLong TS_SEQ = new AtomicLong(); + + private static String nextUniqueTimestamp() { + long now = System.nanoTime(); + while (true) { + long prev = TS_SEQ.get(); + long next = now > prev ? now : prev + 1; + if (TS_SEQ.compareAndSet(prev, next)) { + return String.valueOf(next); + } + } + } } diff --git a/src/main/java/com/sipai/controller/jsyw/VehicleGateController.java b/src/main/java/com/sipai/controller/jsyw/VehicleGateController.java index 9bef676a..4772f2bf 100644 --- a/src/main/java/com/sipai/controller/jsyw/VehicleGateController.java +++ b/src/main/java/com/sipai/controller/jsyw/VehicleGateController.java @@ -1,6 +1,11 @@ package com.sipai.controller.jsyw; +import com.sipai.tools.DeviceAccessHttpUtil; +import com.sipai.tools.HttpUtil; import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; @@ -8,15 +13,27 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicLong; @Controller @RequestMapping("/jsyw/vehicleGate") public class VehicleGateController { + private static final Logger LOG = LoggerFactory.getLogger(VehicleGateController.class); + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final int DEFAULT_REMOTE_PAGE_SIZE = 200; + private static final int MAX_REMOTE_PAGES_PER_DAY = 100; + private static final String DEFAULT_API_URL = "http://127.0.0.1:8090/dh-netsdk/vehicleGate/getDailyRecognizedVehicleList"; + private static final Properties VEHICLE_GATE_PROPS = loadVehicleGateProps(); + private static final AtomicLong TS_SEQ = new AtomicLong(); @RequestMapping("/showList.do") public String showList(HttpServletRequest request, Model model) { @@ -31,14 +48,18 @@ public class VehicleGateController { String passDate = safeStr(request.getParameter("passDate")); String direction = safeStr(request.getParameter("direction")); String status = safeStr(request.getParameter("status")); + LocalDate queryDay = parseDay(passDate); + if (queryDay == null) { + queryDay = LocalDate.now(); + } - List> mockRows = buildMockRows(); + List> remoteRows = loadRowsFromRemote(queryDay, plateNo); List> filteredRows = new ArrayList>(); int inCount = 0; int outCount = 0; int abnormalCount = 0; - for (Map item : mockRows) { + for (Map item : remoteRows) { String itemPlateNo = safeStr(item.get("plateNo")).toUpperCase(Locale.ROOT); String itemPassTime = safeStr(item.get("passTime")); String itemDirection = safeStr(item.get("direction")); @@ -69,46 +90,209 @@ public class VehicleGateController { pageRows = filteredRows.subList(pageStart, pageEnd); } - JSONArray rowsJson = JSONArray.fromObject(pageRows); - String result = "{" - + "\"total\":" + total + "," - + "\"rows\":" + rowsJson + "," - + "\"summaryInCount\":" + inCount + "," - + "\"summaryOutCount\":" + outCount + "," - + "\"summaryInsideCount\":" + (inCount - outCount) + "," - + "\"summaryAbnormalCount\":" + abnormalCount - + "}"; - model.addAttribute("result", result); + JSONObject result = new JSONObject(); + result.put("total", total); + result.put("rows", JSONArray.fromObject(pageRows)); + result.put("summaryInCount", inCount); + result.put("summaryOutCount", outCount); + result.put("summaryInsideCount", inCount - outCount); + result.put("summaryAbnormalCount", abnormalCount); + model.addAttribute("result", result.toString()); return new ModelAndView("result"); } - private String safeStr(Object value) { - return value == null ? "" : String.valueOf(value).trim(); - } - - private List> buildMockRows() { + private List> loadRowsFromRemote(LocalDate day, String plateNo) { List> rows = new ArrayList>(); - rows.add(buildRow("鲁A12345", "IN", "2026-03-03 08:12:21", "东门1号闸", "张三", "NORMAL", "自动识别放行")); - rows.add(buildRow("鲁B66K88", "OUT", "2026-03-03 08:18:46", "东门1号闸", "李四", "NORMAL", "自动识别放行")); - rows.add(buildRow("鲁C99871", "IN", "2026-03-03 08:27:19", "南门2号闸", "王五", "ABNORMAL", "车牌识别异常,人工放行")); - rows.add(buildRow("鲁A77889", "IN", "2026-03-03 09:04:52", "北门1号闸", "赵六", "NORMAL", "自动识别放行")); - rows.add(buildRow("鲁D22319", "OUT", "2026-03-03 09:19:11", "南门2号闸", "钱七", "NORMAL", "自动识别放行")); - rows.add(buildRow("鲁E55120", "IN", "2026-03-03 10:03:35", "西门1号闸", "孙八", "NORMAL", "访客车辆")); - rows.add(buildRow("鲁F90111", "OUT", "2026-03-03 10:16:05", "北门1号闸", "周九", "ABNORMAL", "未登记离场,值班确认")); - rows.add(buildRow("鲁A0P365", "IN", "2026-03-03 10:42:30", "东门1号闸", "吴十", "NORMAL", "自动识别放行")); + int pageSize = getIntProp("vehicleGate.api.pageSize", DEFAULT_REMOTE_PAGE_SIZE); + String url = getProp("vehicleGate.api.url", DEFAULT_API_URL); + + for (int pageNum = 1; pageNum <= MAX_REMOTE_PAGES_PER_DAY; pageNum++) { + com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject(); + req.put("day", DAY_FMT.format(day)); + req.put("plateNumber", plateNo); + req.put("pageNum", pageNum); + req.put("pageSize", pageSize); + + String ts = nextUniqueTimestamp(); + Map headers = buildDhHeaders(ts); + + String respText; + try { + respText = HttpUtil.sendPost(url, req, headers); + } catch (Exception ex) { + LOG.error("VehicleGate request failed, day={}, pageNum={}", day, pageNum, ex); + break; + } + + int fetched = appendRowsFromResponse(rows, respText, day, pageNum); + if (fetched <= 0 || fetched < pageSize) { + break; + } + } return rows; } - private Map buildRow(String plateNo, String direction, String passTime, String gateName, - String driverName, String status, String note) { - Map row = new HashMap(); - row.put("plateNo", plateNo); - row.put("direction", direction); - row.put("passTime", passTime); - row.put("gateName", gateName); - row.put("driverName", driverName); - row.put("status", status); - row.put("note", note); + private int appendRowsFromResponse(List> rows, String respText, LocalDate day, int pageNum) { + if (respText == null || respText.trim().isEmpty()) { + LOG.warn("VehicleGate response empty, day={}, pageNum={}", day, pageNum); + return 0; + } + JSONObject resp; + try { + resp = JSONObject.fromObject(respText); + } catch (Exception ex) { + LOG.error("VehicleGate response parse error, day={}, pageNum={}, body={}", day, pageNum, shorten(respText), ex); + return 0; + } + boolean success = "true".equalsIgnoreCase(String.valueOf(resp.opt("success"))); + if (!success) { + LOG.warn("VehicleGate response unsuccessful, day={}, pageNum={}, message={}", day, pageNum, resp.optString("message")); + return 0; + } + JSONArray data = resp.optJSONArray("data"); + if (data == null || data.isEmpty()) { + return 0; + } + for (int i = 0; i < data.size(); i++) { + Object item = data.get(i); + JSONObject dto = item instanceof JSONObject ? (JSONObject) item : JSONObject.fromObject(item); + rows.add(mapRemoteRecord(dto)); + } + return data.size(); + } + + private static Map buildDhHeaders(String ts) { + String ip = getProp("vehicleGate.api.deviceIp", "192.168.1.108"); + String user = getProp("vehicleGate.api.deviceUser", "admin"); + String port = getProp("vehicleGate.api.devicePort", "37777"); + String plainPwd = getProp("vehicleGate.api.devicePassword", ""); + if (plainPwd.isEmpty()) { + LOG.warn("vehicleGate.api.devicePassword is empty, vehicle gate call may fail auth"); + } + try { + Map headers = DeviceAccessHttpUtil.buildDeviceHeaders(ip, user, plainPwd, ts, port); + headers.put("Content-Type", "application/json"); + return headers; + } catch (Exception ex) { + throw new IllegalStateException("Build vehicle gate headers failed", ex); + } + } + + private static String nextUniqueTimestamp() { + long now = System.nanoTime(); + while (true) { + long prev = TS_SEQ.get(); + long next = now > prev ? now : prev + 1; + if (TS_SEQ.compareAndSet(prev, next)) { + return String.valueOf(next); + } + } + } + + private static Map mapRemoteRecord(JSONObject dto) { + String eventTime = safeStr(dto.optString("eventTime")); + int channel = dto.optInt("channel", -1); + int lane = dto.optInt("lane", -1); + + Map row = new LinkedHashMap(); + row.put("plateNo", safeStr(dto.optString("plateNumber"))); + row.put("direction", resolveDirection(dto)); + row.put("passTime", eventTime); + row.put("gateName", buildGateName(channel, lane)); + row.put("driverName", "--"); + row.put("status", resolveStatus(dto)); + row.put("note", firstNonEmpty(safeStr(dto.optString("allowStatusText")), "通道" + channel + " 车道" + lane)); return row; } + + private static String resolveDirection(JSONObject dto) { + String text = safeStr(dto.optString("allowStatusText")); + if (text.contains("出")) { + return "OUT"; + } + if (text.contains("进") || text.contains("入")) { + return "IN"; + } + int lane = dto.optInt("lane", -1); + if (lane > 0 && lane % 2 == 0) { + return "OUT"; + } + return "IN"; + } + + private static String resolveStatus(JSONObject dto) { + int allow = dto.optInt("allowStatus", -1); + if (allow == 1) { + return "NORMAL"; + } + return "ABNORMAL"; + } + + private static String buildGateName(int channel, int lane) { + if (channel < 0 && lane < 0) { + return "--"; + } + return "通道" + channel + "-车道" + lane; + } + + private static String safeStr(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private static String firstNonEmpty(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + private static LocalDate parseDay(String day) { + if (day == null || day.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(day.trim(), DAY_FMT); + } catch (Exception ex) { + return null; + } + } + + private static Properties loadVehicleGateProps() { + Properties p = new Properties(); + try (InputStream in = VehicleGateController.class.getClassLoader().getResourceAsStream("thirdRequest.properties")) { + if (in != null) { + p.load(in); + } + } catch (Exception ex) { + LOG.warn("Load thirdRequest.properties failed", ex); + } + return p; + } + + private static String getProp(String key, String defaultValue) { + String v = VEHICLE_GATE_PROPS.getProperty(key); + return v == null || v.trim().isEmpty() ? defaultValue : v.trim(); + } + + private static int getIntProp(String key, int defaultValue) { + String v = getProp(key, String.valueOf(defaultValue)); + try { + return Integer.parseInt(v); + } catch (Exception ex) { + return defaultValue; + } + } + + private static String shorten(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replace('\n', ' ').replace('\r', ' '); + return oneLine.length() > 400 ? oneLine.substring(0, 400) + "..." : oneLine; + } } diff --git a/src/main/java/com/sipai/tools/DeviceAccessHttpUtil.java b/src/main/java/com/sipai/tools/DeviceAccessHttpUtil.java new file mode 100644 index 00000000..14480d5b --- /dev/null +++ b/src/main/java/com/sipai/tools/DeviceAccessHttpUtil.java @@ -0,0 +1,261 @@ +package com.sipai.tools; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * 设备HTTP访问工具类: + *
    + *
  • 发送时自动填充设备相关请求头
  • + *
  • 接收时解析并校验请求头,防止重放、参数篡改、时间戳过期等安全风险
  • + *
  • 提供AES加密/解密工具,保护敏感信息
  • + *
  • 支持配置化密钥和时间戳有效期,便于运维和安全加固
  • + *
+ *

+ * 主要头部: + * Dh-Device-Ip、Dh-Device-User、Dh-Device-Password、Dh-Device-Timestamp、Dh-Device-Port + *

+ * 推荐通过Spring注入本工具类,避免静态方法带来的配置注入问题。 + */ +@Component +public class DeviceAccessHttpUtil { + /** + * AES加密密钥,长度16位。可通过配置覆盖,避免硬编码泄露。 + */ + private static String AES_KEY = "NetSDK1234567890"; // 16位密钥 + @Value("${crypto.aes-key:NetSDK1234567890}") + public void setAesKey(String aesKey) { + String key = aesKey == null ? "" : aesKey.trim(); + if (key.length() != 16 && key.length() != 24 && key.length() != 32) { + throw new IllegalArgumentException("crypto.aes-key length must be 16/24/32"); + } + AES_KEY = key; + } + + /** + * 设备请求时间戳有效期(毫秒),默认5分钟。可通过配置覆盖。 + * 注意:内部会转换为微秒进行比较,以支持微秒级时间戳精度。 + */ + private static long TIMESTAMP_EXPIRE_MS = 300000L; + @Value("${device.timestamp.expire-ms:300000}") + public void setTimestampExpireMs(long timestampExpireMs) { + TIMESTAMP_EXPIRE_MS = timestampExpireMs; + } + + private static final String ALGORITHM = "AES"; + + // 设备 HTTP 头部 key 常量,便于全局复用和维护 + public static final String HEADER_DEVICE_IP = "Dh-Device-Ip"; + public static final String HEADER_DEVICE_USER = "Dh-Device-User"; + public static final String HEADER_DEVICE_PASSWORD = "Dh-Device-Password"; + public static final String HEADER_DEVICE_TIMESTAMP = "Dh-Device-Timestamp"; + public static final String HEADER_DEVICE_PORT = "Dh-Device-Port"; + + /** + * 防重放 nonce 集合及最大容量,防止同一加密串被多次使用。 + */ + private static final int NONCE_SET_MAX_SIZE = 10000; + private static final java.util.Set usedNonceSet = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<>()); + + /** + * 构造设备访问请求头,自动加密密码并填充所有必需字段。 + * 使用方式:获取返回结果,遍历map kv,直接填充到请求头。 + * @param ip 设备IP + * @param user 用户名 + * @param password 明文密码 + * @param timestamp 时间戳(建议用System.nanoTime()获取微秒级时间戳) + * @param port 端口号 + * @return 设备请求头Map + * @throws Exception 加密异常 + */ + public static Map buildDeviceHeaders(String ip, String user, String password, String timestamp, String port) throws Exception { + Map headers = new HashMap<>(); + headers.put(HEADER_DEVICE_IP, ip); + headers.put(HEADER_DEVICE_USER, user); + headers.put(HEADER_DEVICE_TIMESTAMP, timestamp); + headers.put(HEADER_DEVICE_PORT, port); + // 密码加密(password|timestamp) + String encPwd = encryptWithTimestamp(password, timestamp); + headers.put(HEADER_DEVICE_PASSWORD, encPwd); + return headers; + } + + /** + * 解析并校验设备请求头,返回解析结果DeviceAccessInfo。 + * 校验项:参数完整性、密码解密、时间戳一致性、防重放、端口校验。 + * 校验失败抛异常。 + * @param request HttpServletRequest + * @return DeviceAccessInfo(ip、user、password、timestamp、port) + * @throws IllegalArgumentException 校验或解密异常 + */ + public static DeviceAccessInfo parseAndValidateDeviceHeaders(javax.servlet.http.HttpServletRequest request) { + String ip = request.getHeader(HEADER_DEVICE_IP); + String encPwd = request.getHeader(HEADER_DEVICE_PASSWORD); + String user = request.getHeader(HEADER_DEVICE_USER); + String ts = request.getHeader(HEADER_DEVICE_TIMESTAMP); + String portStr = request.getHeader(HEADER_DEVICE_PORT); + if (user == null || user.isEmpty()) user = "admin"; + return parseAndValidateDeviceHeaders(ip, encPwd, user, ts, portStr); + } + + /** + * 统一参数校验和解密逻辑,失败抛异常,成功返回DeviceAccessInfo。 + *

    + *
  • 参数完整性校验
  • + *
  • 重放攻击防护(同一加密串只允许用一次)
  • + *
  • 密码解密与格式校验
  • + *
  • 时间戳一致性与有效期校验
  • + *
  • 端口号格式校验
  • + *
+ * @param ip 设备IP + * @param encPwd 加密密码 + * @param user 用户名 + * @param ts 时间戳 + * @param portStr 端口字符串 + * @return DeviceAccessInfo 校验通过的设备信息 + * @throws IllegalArgumentException 校验失败 + */ + public static DeviceAccessInfo parseAndValidateDeviceHeaders(String ip, String encPwd, String user, String ts, String portStr) { + if (ip == null || encPwd == null || ts == null) { + throw new IllegalArgumentException("缺少设备登录信息: ip, encPwd, ts"); + } + String nonce = encPwd; + synchronized (usedNonceSet) { + if (usedNonceSet.contains(nonce)) { + throw new IllegalArgumentException("请求重放: nonce=" + nonce); + } + usedNonceSet.add(nonce); + if (usedNonceSet.size() > NONCE_SET_MAX_SIZE) { + String first = usedNonceSet.iterator().next(); + usedNonceSet.remove(first); + } + } + String[] pwdTsNonce; + try { + pwdTsNonce = decryptWithTimestamp(encPwd); + } catch (Exception e) { + throw new IllegalArgumentException("密码解密异常: " + e.getMessage(), e); + } + if (pwdTsNonce.length != 2) { + throw new IllegalArgumentException("密码解密格式错误: encPwd=" + encPwd); + } + String pwd = pwdTsNonce[0]; + String tsInPwd = pwdTsNonce[1]; + if (!ts.equals(tsInPwd)) { + throw new IllegalArgumentException("时间戳校验失败: ts=" + ts + ", tsInPwd=" + tsInPwd); + } + long now = System.nanoTime(); + long tsLong; + try { tsLong = Long.parseLong(ts); } catch (Exception e) { + throw new IllegalArgumentException("时间戳格式错误: ts=" + ts); + } + if (Math.abs(now - tsLong) > TIMESTAMP_EXPIRE_MS * 1000) { // 转换为微秒进行比较 + throw new IllegalArgumentException("登录请求已过期: now=" + now + ", tsLong=" + tsLong + ", expireMs=" + TIMESTAMP_EXPIRE_MS); + } + int port = 37777; + if (portStr != null && !portStr.isEmpty()) { + try { + port = Integer.parseInt(portStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Device-Port格式错误: portStr=" + portStr); + } + } + return new DeviceAccessInfo(ip, user, pwd, ts, port); + } + + /** + * AES加密(ECB模式,PKCS5Padding),用于加密敏感数据。 + * @param data 明文 + * @return base64编码的密文 + * @throws Exception 加密异常 + */ + private static String encrypt(String data) throws Exception { + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } + + /** + * AES解密(ECB模式,PKCS5Padding),用于解密敏感数据。 + * @param encryptedData base64编码的密文 + * @return 明文 + * @throws Exception 解密异常 + */ + private static String decrypt(String encryptedData) throws Exception { + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] decoded = Base64.getDecoder().decode(encryptedData); + byte[] decrypted = cipher.doFinal(decoded); + return new String(decrypted, StandardCharsets.UTF_8); + } + + /** + * 生成加密字符串:password|timestamp + * @param password 明文密码 + * @param timestamp 时间戳 + * @return 加密后字符串 + * @throws Exception 加密异常 + */ + private static String encryptWithTimestamp(String password, String timestamp) throws Exception { + return encrypt(password + "|" + timestamp); + } + + /** + * 解密并返回[password, timestamp] + * @param encryptedData 加密后字符串 + * @return [password, timestamp] + * @throws Exception 解密异常 + */ + private static String[] decryptWithTimestamp(String encryptedData) throws Exception { + String plain = decrypt(encryptedData); + return plain.split("\\|", 2); + } + + + /** + * 设备访问信息结构体,封装所有校验通过的关键信息。 + * 用于业务层安全登录、日志审计等场景。 + */ + public static class DeviceAccessInfo { + /** 设备IP */ + public final String ip; + /** 用户名 */ + public final String user; + /** 明文密码(已解密) */ + public final String password; + /** 时间戳 */ + public final String timestamp; + /** 端口号 */ + public final int port; + public DeviceAccessInfo(String ip, String user, String password, String timestamp, int port) { + this.ip = ip; + this.user = user; + this.password = password; + this.timestamp = timestamp; + this.port = port; + } + } + + /** + * 本地测试主方法,演示加密解密流程。 + */ + public static void main(String[] args) throws Exception { + String password = "admin"; + String timestamp = String.valueOf(System.nanoTime()); + String encrypted = encryptWithTimestamp(password, timestamp); + System.out.println("加密后: " + encrypted); + String[] decrypted = decryptWithTimestamp(encrypted); + System.out.println("解密后 password: " + decrypted[0]); + System.out.println("解密后 timestamp: " + decrypted[1]); + } +} \ No newline at end of file diff --git a/src/main/resources/thirdRequest.properties b/src/main/resources/thirdRequest.properties index cef95f56..ac7a317a 100644 --- a/src/main/resources/thirdRequest.properties +++ b/src/main/resources/thirdRequest.properties @@ -3,3 +3,28 @@ boturl=http://10.194.10.169:9206/nsapi/jqrsssj bottoken=http://10.194.10.169:9206/nsapi/getoken?objkey=sipai #interfaces=http://192.168.1.137:8080/BLG/assayMonthWater.do?method=sampleinto¶ms= #interfaces=http://localhost:8099/SIPAIIS_WMS_HQAQ/whp/test/WhpSamplingPlanTaskAudit/test1111.do?method=sampleinto¶ms= + +# Attendance third-party API settings +attendance.api.url=http://127.0.0.1:8090/dh-netsdk/attendance/getDailyRecordList +attendance.api.pageSize=200 +attendance.api.deviceIp=192.168.1.108 +attendance.api.deviceUser=admin +attendance.api.devicePort=37777 +# Plain password used for header encryption. +attendance.api.devicePassword= + +# DeviceAccessHttpUtil crypto settings +# AES key length must be 16/24/32 characters +crypto.aes-key=NetSDK1234567890 +# Timestamp expiration (ms) +device.timestamp.expire-ms=300000 + +# Vehicle gate third-party API settings (independent from attendance.api.*) +vehicleGate.api.url=http://127.0.0.1:8090/dh-netsdk/vehicleGate/getDailyRecognizedVehicleList +vehicleGate.api.pageSize=200 +vehicleGate.api.deviceIp=192.168.1.108 +vehicleGate.api.deviceUser=admin +vehicleGate.api.devicePort=37777 +# Plain password used for header encryption. +vehicleGate.api.devicePassword= + diff --git a/src/main/webapp/jsp/administration/attendanceRecordList.jsp b/src/main/webapp/jsp/administration/attendanceRecordList.jsp index bfdc2bf1..7fcc6832 100644 --- a/src/main/webapp/jsp/administration/attendanceRecordList.jsp +++ b/src/main/webapp/jsp/administration/attendanceRecordList.jsp @@ -147,7 +147,7 @@

员工考勤记录

- 当前为第三方考勤接口模拟数据(MOCK) + 当前为第三方考勤接口实时数据