diff --git a/web-adapter/src/main/java/com/zcloud/primeport/web/close/ClosedAreaController.java b/web-adapter/src/main/java/com/zcloud/primeport/web/close/ClosedAreaController.java index 5299056..19e7ec0 100644 --- a/web-adapter/src/main/java/com/zcloud/primeport/web/close/ClosedAreaController.java +++ b/web-adapter/src/main/java/com/zcloud/primeport/web/close/ClosedAreaController.java @@ -127,6 +127,12 @@ public class ClosedAreaController { return closedAreaService.count(closedAreaCountCmd); } + @ApiOperation("区域人员和车辆定位统计") + @PostMapping("/positionCount") + public MultiResponse positionCount(@RequestBody ClosedAreaCountCmd closedAreaCountCmd) { + return closedAreaService.positionCount(closedAreaCountCmd); + } + @ApiOperation("可视化大屏-封闭区域统计") @PostMapping("/corpStat") public SingleResponse corpStat( @RequestBody ClosedAreaCorpStatCmd cmd) { diff --git a/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaMatcher.java b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaMatcher.java new file mode 100644 index 0000000..68b9c41 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaMatcher.java @@ -0,0 +1,105 @@ +package com.zcloud.primeport.command.query; + +import com.alibaba.cola.exception.BizException; +import com.zcloud.primeport.persistence.dataobject.ClosedAreaCountDO; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Component +class ClosedAreaMatcher { + List prepare(List areas) { + if (areas == null || areas.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(areas.size()); + for (ClosedAreaCountDO area : areas) { + if (StringUtils.hasText(area.getLocation())) { + result.add(new AreaPolygon(area, parseLocation(area))); + } + } + return result; + } + + AreaPolygon match(BigDecimal lon, BigDecimal lat, List areas) { + if (lon == null || lat == null || areas == null) { + return null; + } + for (AreaPolygon area : areas) { + if (contains(lon, lat, area)) { + return area; + } + } + return null; + } + + private boolean contains(BigDecimal lon, BigDecimal lat, AreaPolygon area) { + if (area == null || area.points.size() < 3) { + return false; + } + boolean inside = false; + double x = lon.doubleValue(); + double y = lat.doubleValue(); + for (int i = 0, j = area.points.size() - 1; i < area.points.size(); j = i++) { + double xi = area.points.get(i)[0].doubleValue(); + double yi = area.points.get(i)[1].doubleValue(); + double xj = area.points.get(j)[0].doubleValue(); + double yj = area.points.get(j)[1].doubleValue(); + if ((yi > y) != (yj > y) + && x < (xj - xi) * (y - yi) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + } + + private List parseLocation(ClosedAreaCountDO area) { + List points = new ArrayList<>(); + if (area == null || !StringUtils.hasText(area.getLocation())) { + throw locationFormatException(area); + } + for (String coordinate : area.getLocation().split(";")) { + if (!StringUtils.hasText(coordinate)) { + continue; + } + String[] values = coordinate.trim().split(","); + if (values.length != 2) { + throw locationFormatException(area); + } + try { + points.add(new BigDecimal[]{ + new BigDecimal(values[0].trim()), + new BigDecimal(values[1].trim()) + }); + } catch (NumberFormatException e) { + throw locationFormatException(area); + } + } + if (points.size() < 3) { + throw locationFormatException(area); + } + return points; + } + + private BizException locationFormatException(ClosedAreaCountDO area) { + return new BizException("closed area location format error, areaId=" + (area == null ? null : area.getId())); + } + + static class AreaPolygon { + private final ClosedAreaCountDO area; + private final List points; + + private AreaPolygon(ClosedAreaCountDO area, List points) { + this.area = area; + this.points = points; + } + + ClosedAreaCountDO getArea() { + return area; + } + } +} diff --git a/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocation.java b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocation.java new file mode 100644 index 0000000..aedc726 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocation.java @@ -0,0 +1,19 @@ +package com.zcloud.primeport.command.query; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +class ClosedAreaPersonLocation { + private Long userId; + private String staffName; + private Long corpinfoId; + private String corpinfoName; + private String terminalNo; + private String locationStatus; + private Long lastLocationTime; + private String currentLocation; + private BigDecimal lon; + private BigDecimal lat; +} diff --git a/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocationProvider.java b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocationProvider.java new file mode 100644 index 0000000..2a845cb --- /dev/null +++ b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPersonLocationProvider.java @@ -0,0 +1,320 @@ +package com.zcloud.primeport.command.query; + +import com.alibaba.cola.exception.BizException; +import com.fasterxml.jackson.databind.JsonNode; +import com.zcloud.primeport.integration.finds.PrimePortFindsOpenApiClient; +import com.zcloud.primeport.persistence.dataobject.ClosedAreaPersonLocationDO; +import com.zcloud.primeport.persistence.dataobject.ClosedAreaTerminalBindingDO; +import com.zcloud.primeport.persistence.mapper.ClosedAreaPersonLocationMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +@RequiredArgsConstructor +class ClosedAreaPersonLocationProvider { + private static final String POINT_LOCATE_API = "finds.point.locate"; + private static final String STATUS_ONLINE = "ONLINE"; + private static final int LOCATE_BATCH_SIZE = 100; + private static final int FINDS_BATCH_CONCURRENCY = 4; + + private final ClosedAreaPersonLocationMapper locationMapper; + private final PrimePortFindsOpenApiClient findsOpenApiClient; + + List listOnlinePersons(Long corpinfoId) { + if (corpinfoId == null) { + return Collections.emptyList(); + } + List matched = locationMapper.listMatchedPersons(corpinfoId); + if (matched == null || matched.isEmpty()) { + return Collections.emptyList(); + } + fillMissingTerminalNos(matched); + Map terminals = loadTerminals(matched); + Set terminalNosToLocate = collectOnlineTerminalNos(matched, terminals); + Map currentLocations = locateTerminals(terminalNosToLocate); + List result = new ArrayList<>(); + Set userIds = new LinkedHashSet<>(); + for (ClosedAreaPersonLocationDO person : matched) { + if (person.getUserId() != null && !userIds.add(person.getUserId())) { + continue; + } + ClosedAreaPersonLocationDO terminal = terminals.get(person.getFindsTerminalNo()); + if (!isStoredOnline(person, terminal)) { + continue; + } + JsonNode currentLocation = currentLocations.get(person.getFindsTerminalNo()); + if (!hasLocation(currentLocation)) { + continue; + } + result.add(toLocation(person, terminal, currentLocation)); + } + return result; + } + + private void fillMissingTerminalNos(List matched) { + Set idCardNos = new LinkedHashSet<>(); + for (ClosedAreaPersonLocationDO person : matched) { + if (!StringUtils.hasText(person.getFindsTerminalNo()) + && StringUtils.hasText(person.getIdCardNo())) { + idCardNos.add(person.getIdCardNo().trim()); + } + } + if (idCardNos.isEmpty()) { + return; + } + Map terminalNosByIdCardNo = new LinkedHashMap<>(); + for (List batch : batches(idCardNos)) { + for (ClosedAreaTerminalBindingDO binding : locationMapper.listTerminalBindingsByIdCardNos(batch)) { + String idCardNo = firstText(binding.getBindingIdCardNo(), binding.getTerminalIdCardNo()); + if (StringUtils.hasText(idCardNo) && StringUtils.hasText(binding.getTerminalNo())) { + terminalNosByIdCardNo.putIfAbsent(idCardNo.trim(), binding.getTerminalNo().trim()); + } + } + } + for (ClosedAreaPersonLocationDO person : matched) { + if (!StringUtils.hasText(person.getFindsTerminalNo())) { + person.setFindsTerminalNo(terminalNosByIdCardNo.get(normalize(person.getIdCardNo()))); + } + } + } + + private Map loadTerminals(List matched) { + Set terminalNos = new LinkedHashSet<>(); + Map result = new LinkedHashMap<>(); + for (ClosedAreaPersonLocationDO person : matched) { + String terminalNo = normalize(person.getFindsTerminalNo()); + if (!StringUtils.hasText(terminalNo)) { + continue; + } + terminalNos.add(terminalNo); + if (StringUtils.hasText(person.getTerminalNo())) { + result.putIfAbsent(terminalNo, person); + } + } + for (List batch : batches(terminalNos)) { + for (ClosedAreaPersonLocationDO terminal : locationMapper.listTerminals(batch)) { + if (StringUtils.hasText(terminal.getTerminalNo())) { + result.putIfAbsent(terminal.getTerminalNo(), terminal); + } + } + } + return result; + } + + private Set collectOnlineTerminalNos(List matched, + Map terminals) { + Set result = new LinkedHashSet<>(); + for (ClosedAreaPersonLocationDO person : matched) { + String terminalNo = normalize(person.getFindsTerminalNo()); + if (StringUtils.hasText(terminalNo) && isStoredOnline(person, terminals.get(terminalNo))) { + result.add(terminalNo); + } + } + return result; + } + + private Map locateTerminals(Set terminalNos) { + if (terminalNos == null || terminalNos.isEmpty()) { + return Collections.emptyMap(); + } + List> batches = batches(terminalNos); + List> requests = new ArrayList<>(batches.size()); + for (List batch : batches) { + requests.add(Collections.singletonMap("terminalNoList", batch)); + } + List roots = findsOpenApiClient.postFailFastBatch( + POINT_LOCATE_API, requests, FINDS_BATCH_CONCURRENCY); + Map result = new LinkedHashMap<>(); + for (int index = 0; index < roots.size(); index++) { + appendLocatedTerminals(batches.get(index), roots.get(index), result); + } + return result; + } + + private void appendLocatedTerminals(List terminalNos, JsonNode root, Map result) { + JsonNode data = assertSuccess(root).path("data"); + JsonNode rows = extractRows(data); + if (rows == null || !rows.isArray()) { + return; + } + for (JsonNode row : rows) { + String terminalNo = text(firstPresent(row, "terminalNo")); + if (!StringUtils.hasText(terminalNo) && terminalNos.size() == 1) { + terminalNo = terminalNos.get(0); + } + if (StringUtils.hasText(terminalNo)) { + result.put(terminalNo, row); + } + } + } + + private JsonNode assertSuccess(JsonNode root) { + int code = root == null ? -1 : root.path("code").asInt(-1); + if (code != 200 && code != 0) { + String message = root == null ? "" : root.path("msg").asText(""); + throw new BizException("FindS point locate failed, code=" + code + ", message=" + message); + } + return root; + } + + private ClosedAreaPersonLocation toLocation(ClosedAreaPersonLocationDO person, + ClosedAreaPersonLocationDO terminal, + JsonNode currentLocation) { + ClosedAreaPersonLocation location = new ClosedAreaPersonLocation(); + location.setUserId(person.getUserId()); + location.setStaffName(firstText(person.getStaffName(), person.getFindsStaffName())); + location.setCorpinfoId(person.getCorpinfoId()); + location.setCorpinfoName(person.getCorpinfoName()); + location.setTerminalNo(person.getFindsTerminalNo()); + location.setLocationStatus(STATUS_ONLINE); + location.setCurrentLocation(locationName(currentLocation)); + location.setLastLocationTime(firstLong(locationTime(currentLocation), terminalLastLocationTime(terminal))); + location.setLon(decimal(firstPresent(currentLocation, "lon", "lng", "longitude"))); + location.setLat(decimal(firstPresent(currentLocation, "lat", "latitude"))); + return location; + } + + private boolean isStoredOnline(ClosedAreaPersonLocationDO person, ClosedAreaPersonLocationDO terminal) { + if (person != null && StringUtils.hasText(person.getLocationStatus())) { + return STATUS_ONLINE.equalsIgnoreCase(person.getLocationStatus()); + } + String deviceStatus = firstText( + person == null ? null : person.getFindsDeviceStatus(), + terminal == null ? null : terminal.getTerminalDeviceStatus()); + return STATUS_ONLINE.equalsIgnoreCase(deviceStatus) + || "STATIC".equalsIgnoreCase(deviceStatus) + || "MOVING".equalsIgnoreCase(deviceStatus); + } + + private boolean hasLocation(JsonNode location) { + return decimal(firstPresent(location, "lon", "lng", "longitude")) != null + && decimal(firstPresent(location, "lat", "latitude")) != null; + } + + private JsonNode extractRows(JsonNode data) { + if (data == null || data.isNull() || data.isMissingNode()) { + return null; + } + if (data.isArray()) { + return data; + } + for (String field : new String[]{"data", "rows", "list", "records"}) { + JsonNode value = data.get(field); + if (value != null && value.isArray()) { + return value; + } + } + return null; + } + + private String locationName(JsonNode location) { + return firstText(text(firstPresent(location, "fenceName")), + text(firstPresent(location, "areaName")), + text(firstPresent(location, "positionName")), + text(firstPresent(location, "location")), + text(firstPresent(location, "address"))); + } + + private Long locationTime(JsonNode location) { + JsonNode node = firstPresent(location, "gt", "time", "locateTime", "locationTime", "timestamp"); + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + try { + long value = Long.parseLong(node.asText()); + return value < 100000000000L ? value * 1000L : value; + } catch (NumberFormatException ignored) { + return null; + } + } + + private Long terminalLastLocationTime(ClosedAreaPersonLocationDO terminal) { + LocalDateTime value = terminal == null ? null : terminal.getTerminalLastLocationTime(); + return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + + private JsonNode firstPresent(JsonNode node, String... fields) { + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + for (String field : fields) { + JsonNode value = node.get(field); + if (value != null && !value.isNull() && !value.isMissingNode()) { + return value; + } + } + return null; + } + + private String text(JsonNode node) { + return node == null || node.isNull() || node.isMissingNode() ? null : node.asText(); + } + + private BigDecimal decimal(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + try { + return new BigDecimal(node.asText()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private List> batches(Set values) { + List normalized = new ArrayList<>(); + if (values != null) { + for (String value : values) { + String item = normalize(value); + if (StringUtils.hasText(item)) { + normalized.add(item); + } + } + } + List> result = new ArrayList<>(); + for (int from = 0; from < normalized.size(); from += LOCATE_BATCH_SIZE) { + result.add(normalized.subList(from, Math.min(from + LOCATE_BATCH_SIZE, normalized.size()))); + } + return result; + } + + private String normalize(String value) { + return value == null ? null : value.trim(); + } + + private String firstText(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private Long firstLong(Long... values) { + if (values == null) { + return null; + } + for (Long value : values) { + if (value != null) { + return value; + } + } + return null; + } +} diff --git a/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPositionCountExe.java b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPositionCountExe.java new file mode 100644 index 0000000..6731ce8 --- /dev/null +++ b/web-app/src/main/java/com/zcloud/primeport/command/query/ClosedAreaPositionCountExe.java @@ -0,0 +1,129 @@ +package com.zcloud.primeport.command.query; + +import com.alibaba.cola.dto.MultiResponse; +import com.jjb.saas.framework.auth.utils.AuthContext; +import com.zcloud.gbscommon.utils.PageQueryHelper; +import com.zcloud.primeport.command.convertor.ClosedAreaCountCoConvertor; +import com.zcloud.primeport.dto.ClosedAreaCountCmd; +import com.zcloud.primeport.dto.clientobject.ClosedAreaCountCO; +import com.zcloud.primeport.persistence.dataobject.ClosedAreaCountDO; +import com.zcloud.primeport.persistence.repository.ClosedAreaRepository; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@AllArgsConstructor +public class ClosedAreaPositionCountExe { + private final ClosedAreaRepository closedAreaRepository; + private final ClosedAreaCountCoConvertor closedAreaCountCoConvertor; + private final ClosedAreaPersonLocationProvider personLocationProvider; + private final ClosedAreaMatcher closedAreaMatcher; + + public MultiResponse execute(ClosedAreaCountCmd cmd) { + Map params = PageQueryHelper.toHashMap(cmd); + List rows = closedAreaRepository.listPositionCount(params); + fillRealtimeCounts(rows, cmd); + List result = closedAreaCountCoConvertor.converDOsToCOs(rows); + for (ClosedAreaCountCO item : result) { + item.setChildren(treeChildren(item.getId(), rows)); + } + for (ClosedAreaCountCO item : result) { + sumChildrenCount(item); + } + return MultiResponse.of(result); + } + + private void fillRealtimeCounts(List areas, ClosedAreaCountCmd cmd) { + if (areas == null || areas.isEmpty()) { + return; + } + Map> areasByCorpinfoId = groupAreasByCorpinfoId(areas, cmd); + for (Map.Entry> entry : areasByCorpinfoId.entrySet()) { + List polygons = closedAreaMatcher.prepare(entry.getValue()); + if (polygons.isEmpty()) { + continue; + } + List persons = personLocationProvider.listOnlinePersons(entry.getKey()); + Map personCounts = countPersonsByArea(persons, polygons); + for (ClosedAreaCountDO area : entry.getValue()) { + area.setPersonCount(personCounts.getOrDefault(area.getId(), 0)); + area.setCarCount(0); + } + } + for (ClosedAreaCountDO area : areas) { + if (area.getPersonCount() == null) { + area.setPersonCount(0); + } + area.setCarCount(0); + } + } + + private Map> groupAreasByCorpinfoId(List areas, + ClosedAreaCountCmd cmd) { + Map> result = new LinkedHashMap<>(); + Long fallbackCorpinfoId = cmd == null ? null : cmd.getCorpId(); + for (ClosedAreaCountDO area : areas) { + Long corpinfoId = area.getJurisdictionalCorpId() == null + ? fallbackCorpinfoId : area.getJurisdictionalCorpId(); + if (corpinfoId == null) { + continue; + } + result.computeIfAbsent(corpinfoId, key -> new ArrayList<>()).add(area); + } + return result; + } + + private Map countPersonsByArea(List persons, + List polygons) { + if (persons == null || persons.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(); + for (ClosedAreaPersonLocation person : persons) { + ClosedAreaMatcher.AreaPolygon matched = closedAreaMatcher.match( + person.getLon(), person.getLat(), polygons); + if (matched == null || matched.getArea().getId() == null) { + continue; + } + Long areaId = matched.getArea().getId(); + result.put(areaId, result.getOrDefault(areaId, 0) + 1); + } + return result; + } + + private List treeChildren(Long parentId, List rows) { + List children = new ArrayList<>(); + for (ClosedAreaCountDO row : rows) { + if (parentId.equals(row.getParentId())) { + children.add(closedAreaCountCoConvertor.converDOToCO(row)); + } + } + for (ClosedAreaCountCO child : children) { + child.setChildren(treeChildren(child.getId(), rows)); + } + return children.isEmpty() ? null : children; + } + + private void sumChildrenCount(ClosedAreaCountCO node) { + List children = node.getChildren(); + if (children == null || children.isEmpty()) { + return; + } + int personSum = 0; + int carSum = 0; + for (ClosedAreaCountCO child : children) { + sumChildrenCount(child); + personSum += child.getPersonCount() == null ? 0 : child.getPersonCount(); + carSum += child.getCarCount() == null ? 0 : child.getCarCount(); + } + node.setPersonCount((node.getPersonCount() == null ? 0 : node.getPersonCount()) + personSum); + node.setCarCount((node.getCarCount() == null ? 0 : node.getCarCount()) + carSum); + } +} diff --git a/web-app/src/main/java/com/zcloud/primeport/integration/finds/PrimePortFindsOpenApiClient.java b/web-app/src/main/java/com/zcloud/primeport/integration/finds/PrimePortFindsOpenApiClient.java new file mode 100644 index 0000000..85e357b --- /dev/null +++ b/web-app/src/main/java/com/zcloud/primeport/integration/finds/PrimePortFindsOpenApiClient.java @@ -0,0 +1,221 @@ +package com.zcloud.primeport.integration.finds; + +import com.alibaba.cola.exception.BizException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jjb.saas.framework.auth.model.SSOUser; +import com.jjb.saas.framework.auth.utils.AuthContext; +import com.zcloud.primeport.persistence.dataobject.OpenapiConfigDO; +import com.zcloud.primeport.persistence.mapper.ClosedAreaPersonLocationMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +@Component +@RequiredArgsConstructor +public class PrimePortFindsOpenApiClient { + private static final String SIK_PATH_SEGMENT = "sik"; + + @Value("${finds.openapi.fail-fast-connect-timeout-ms:5000}") + private int failFastConnectTimeoutMs = 5000; + + @Value("${finds.openapi.fail-fast-read-timeout-ms:15000}") + private int failFastReadTimeoutMs = 15000; + + private final ClosedAreaPersonLocationMapper locationMapper; + private final ObjectMapper objectMapper; + + public List postFailFastBatch(String apiName, List> requests, int maxConcurrency) { + if (requests == null || requests.isEmpty()) { + return Collections.emptyList(); + } + OpenapiConfigDO config = loadEnabledConfig(); + int concurrency = Math.max(1, Math.min(Math.min(maxConcurrency, 8), requests.size())); + if (concurrency == 1) { + List result = new ArrayList<>(requests.size()); + for (Map request : requests) { + result.add(post(config, apiName, request)); + } + return result; + } + ExecutorService executor = Executors.newFixedThreadPool(concurrency); + try { + List> futures = new ArrayList<>(requests.size()); + for (Map request : requests) { + futures.add(executor.submit(() -> post(config, apiName, request))); + } + List result = new ArrayList<>(requests.size()); + for (Future future : futures) { + result.add(future.get()); + } + return result; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BizException("FindS OpenAPI batch request interrupted"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new BizException("FindS OpenAPI batch request failed: " + cause.getMessage()); + } finally { + executor.shutdownNow(); + } + } + + private OpenapiConfigDO loadEnabledConfig() { + SSOUser currentUser = AuthContext.getCurrentUser(); + Long companyId = currentUser == null ? null : currentUser.getCompanyId(); + OpenapiConfigDO config = locationMapper.findEnabledOpenapiConfig(companyId); + if (config == null) { + throw new BizException("FindS OpenAPI enabled config not found"); + } + if (!StringUtils.hasText(config.getHostUrl()) + || !StringUtils.hasText(config.getSik()) + || !StringUtils.hasText(config.getSisCipherText())) { + throw new BizException("FindS OpenAPI config is incomplete"); + } + return config; + } + + private JsonNode post(OpenapiConfigDO config, String apiName, Map request) { + String requestJson = writeJson(request == null ? Collections.emptyMap() : request); + String timestamp = String.valueOf(System.currentTimeMillis()); + String externalPrefix = normalizePrefix(defaultIfBlank(config.getExternalPrefix(), "/rest/")); + String internalPrefix = normalizePrefix(defaultIfBlank(config.getInternalPrefix(), "/rest/")); + String signPath = buildApiPath(externalPrefix, apiName, config.getSik()); + String requestPath = buildApiPath(internalPrefix, apiName, config.getSik()); + String signature = sign(signPath, requestJson, config.getSisCipherText(), timestamp); + String url = trimTrailingSlash(config.getHostUrl()) + requestPath + "?_sign=" + signature; + String response = doPost(url, requestJson, timestamp); + try { + return objectMapper.readTree(response); + } catch (IOException e) { + throw new BizException("FindS response is not valid JSON: " + apiName); + } + } + + private String buildApiPath(String prefix, String apiName, String sik) { + return prefix + apiName + "/" + SIK_PATH_SEGMENT + "/" + sik; + } + + private String sign(String path, String requestJson, String sis, String timestamp) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(sis.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update(path.getBytes(StandardCharsets.UTF_8)); + mac.update("request".getBytes(StandardCharsets.UTF_8)); + mac.update(requestJson.getBytes(StandardCharsets.UTF_8)); + mac.update(timestamp.getBytes(StandardCharsets.UTF_8)); + return toUpperHex(mac.doFinal()); + } catch (Exception e) { + throw new BizException("FindS OpenAPI sign failed"); + } + } + + private String doPost(String url, String requestJson, String timestamp) { + HttpURLConnection connection = null; + try { + byte[] body = ("request=" + URLEncoder.encode(requestJson, "UTF-8")) + .getBytes(StandardCharsets.UTF_8); + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(Math.max(failFastConnectTimeoutMs, 1)); + connection.setReadTimeout(Math.max(failFastReadTimeoutMs, 1)); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + connection.setRequestProperty("wz-acs-timestamp", timestamp); + connection.setRequestProperty("Accept", "application/json"); + try (OutputStream outputStream = connection.getOutputStream()) { + outputStream.write(body); + } + int status = connection.getResponseCode(); + String response = readResponse(status >= 400 + ? connection.getErrorStream() : connection.getInputStream()); + if (status != HttpURLConnection.HTTP_OK) { + throw new BizException("FindS OpenAPI HTTP failed, status=" + status + ", body=" + response); + } + return response; + } catch (IOException e) { + throw new BizException("FindS OpenAPI connection failed: " + e.getMessage()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private String readResponse(InputStream inputStream) throws IOException { + if (inputStream == null) { + return ""; + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, len); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (IOException e) { + throw new BizException("FindS request JSON convert failed"); + } + } + + private String toUpperHex(byte[] bytes) { + char[] digits = "0123456789ABCDEF".toCharArray(); + char[] result = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + result[i * 2] = digits[(bytes[i] & 0xF0) >> 4]; + result[i * 2 + 1] = digits[bytes[i] & 0x0F]; + } + return new String(result); + } + + private String normalizePrefix(String prefix) { + String value = prefix.trim(); + if (!value.startsWith("/")) { + value = "/" + value; + } + if (!value.endsWith("/")) { + value = value + "/"; + } + return value; + } + + private String trimTrailingSlash(String value) { + String result = value.trim(); + while (result.endsWith("/")) { + result = result.substring(0, result.length() - 1); + } + return result; + } + + private String defaultIfBlank(String value, String defaultValue) { + return StringUtils.hasText(value) ? value : defaultValue; + } +} diff --git a/web-app/src/main/java/com/zcloud/primeport/service/ClosedAreaServiceImpl.java b/web-app/src/main/java/com/zcloud/primeport/service/ClosedAreaServiceImpl.java index 5b22a91..e36558e 100644 --- a/web-app/src/main/java/com/zcloud/primeport/service/ClosedAreaServiceImpl.java +++ b/web-app/src/main/java/com/zcloud/primeport/service/ClosedAreaServiceImpl.java @@ -8,6 +8,7 @@ import com.zcloud.primeport.command.ClosedAreaAddExe; import com.zcloud.primeport.command.ClosedAreaRemoveExe; import com.zcloud.primeport.command.ClosedAreaUpdateExe; import com.zcloud.primeport.command.query.ClosedAreaCountExe; +import com.zcloud.primeport.command.query.ClosedAreaPositionCountExe; import com.zcloud.primeport.command.query.ClosedAreaQueryExe; import com.zcloud.primeport.dto.ClosedAreaAddCmd; import com.zcloud.primeport.dto.ClosedAreaCorpStatCmd; @@ -38,6 +39,7 @@ public class ClosedAreaServiceImpl implements ClosedAreaServiceI { private final ClosedAreaRemoveExe closedAreaRemoveExe; private final ClosedAreaQueryExe closedAreaQueryExe; private final ClosedAreaCountExe closedAreaCountExe; + private final ClosedAreaPositionCountExe closedAreaPositionCountExe; private final ClosedAreaRepository closedAreaRepository; @Override @@ -71,6 +73,11 @@ public class ClosedAreaServiceImpl implements ClosedAreaServiceI { return closedAreaCountExe.execute(cmd); } + @Override + public MultiResponse positionCount(ClosedAreaCountCmd cmd) { + return closedAreaPositionCountExe.execute(cmd); + } + @Override public SingleResponse corpStat(ClosedAreaCorpStatCmd cmd) { return closedAreaCountExe.corpStat(cmd); diff --git a/web-client/src/main/java/com/zcloud/primeport/api/ClosedAreaServiceI.java b/web-client/src/main/java/com/zcloud/primeport/api/ClosedAreaServiceI.java index 85fb069..1ef36f2 100644 --- a/web-client/src/main/java/com/zcloud/primeport/api/ClosedAreaServiceI.java +++ b/web-client/src/main/java/com/zcloud/primeport/api/ClosedAreaServiceI.java @@ -28,6 +28,8 @@ public interface ClosedAreaServiceI { MultiResponse count(ClosedAreaCountCmd cmd); + MultiResponse positionCount(ClosedAreaCountCmd cmd); + SingleResponse corpStat(ClosedAreaCorpStatCmd cmd); void remove(Long id); diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaPersonLocationDO.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaPersonLocationDO.java new file mode 100644 index 0000000..6120976 --- /dev/null +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaPersonLocationDO.java @@ -0,0 +1,30 @@ +package com.zcloud.primeport.persistence.dataobject; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ClosedAreaPersonLocationDO { + private Long userId; + private String localStaffNo; + private String staffName; + private String mobileNo; + private String idCardNo; + private Long corpinfoId; + private String corpinfoName; + private Long departmentId; + private String departmentName; + private String findsStaffNo; + private String findsStaffName; + private String findsTerminalNo; + private String locationStatus; + private String findsDeviceStatus; + private Long locationAreaId; + private String locationAreaName; + private String terminalNo; + private String terminalDeviceType; + private String terminalDeviceStatus; + private LocalDateTime terminalLastLocationTime; + private String terminalLastLocationName; +} diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaTerminalBindingDO.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaTerminalBindingDO.java new file mode 100644 index 0000000..4857752 --- /dev/null +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/ClosedAreaTerminalBindingDO.java @@ -0,0 +1,10 @@ +package com.zcloud.primeport.persistence.dataobject; + +import lombok.Data; + +@Data +public class ClosedAreaTerminalBindingDO { + private String bindingIdCardNo; + private String terminalIdCardNo; + private String terminalNo; +} diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/OpenapiConfigDO.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/OpenapiConfigDO.java new file mode 100644 index 0000000..2318af0 --- /dev/null +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/dataobject/OpenapiConfigDO.java @@ -0,0 +1,22 @@ +package com.zcloud.primeport.persistence.dataobject; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.jjb.saas.framework.repository.basedo.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("openapi_config") +public class OpenapiConfigDO extends BaseDO { + private String configName; + private String configCode; + private Long corpinfoId; + private String corpinfoName; + private String hostUrl; + private String externalPrefix; + private String internalPrefix; + private String sik; + private String sisCipherText; + private Integer enabled; +} diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaMapper.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaMapper.java index 4082897..9f34106 100644 --- a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaMapper.java +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaMapper.java @@ -22,14 +22,17 @@ import java.util.Map; * @Date 2026-03-19 10:27:51 */ @Mapper -@DataScopes( - @DataScope(method = "listPage", menuPerms = "") -) +@DataScopes({ + @DataScope(method = "listPage", menuPerms = ""), + @DataScope(method = "listPositionCount", menuPerms = "") +}) public interface ClosedAreaMapper extends BaseMapper { List listCount(@Param("params") Map params); + List listPositionCount(@Param("params") Map params); + ClosedAreaCorpStatDO corpStat(@Param("params") Map params); List getCountByHgAuthArea(); diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaPersonLocationMapper.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaPersonLocationMapper.java new file mode 100644 index 0000000..7f8397d --- /dev/null +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/mapper/ClosedAreaPersonLocationMapper.java @@ -0,0 +1,20 @@ +package com.zcloud.primeport.persistence.mapper; + +import com.zcloud.primeport.persistence.dataobject.ClosedAreaPersonLocationDO; +import com.zcloud.primeport.persistence.dataobject.ClosedAreaTerminalBindingDO; +import com.zcloud.primeport.persistence.dataobject.OpenapiConfigDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ClosedAreaPersonLocationMapper { + List listMatchedPersons(@Param("corpinfoId") Long corpinfoId); + + List listTerminalBindingsByIdCardNos(@Param("values") List values); + + List listTerminals(@Param("values") List values); + + OpenapiConfigDO findEnabledOpenapiConfig(@Param("corpinfoId") Long corpinfoId); +} diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/ClosedAreaRepository.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/ClosedAreaRepository.java index cd26153..b0b2a3a 100644 --- a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/ClosedAreaRepository.java +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/ClosedAreaRepository.java @@ -21,6 +21,8 @@ public interface ClosedAreaRepository extends BaseRepository { List listCount(Map params); + List listPositionCount(Map params); + ClosedAreaCorpStatDO corpStat(Map params); List getCountByHgAuthArea(); diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/impl/ClosedAreaRepositoryImpl.java b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/impl/ClosedAreaRepositoryImpl.java index 99866df..7ff44fc 100644 --- a/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/impl/ClosedAreaRepositoryImpl.java +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/persistence/repository/impl/ClosedAreaRepositoryImpl.java @@ -45,6 +45,11 @@ public class ClosedAreaRepositoryImpl extends BaseRepositoryImpl listPositionCount(Map params) { + return closedAreaMapper.listPositionCount(params); + } + @Override public ClosedAreaCorpStatDO corpStat(Map params) { return closedAreaMapper.corpStat(params); diff --git a/web-infrastructure/src/main/java/com/zcloud/primeport/plan/HkAccessRecordSyncXxlJob.java b/web-infrastructure/src/main/java/com/zcloud/primeport/plan/HkAccessRecordSyncXxlJob.java index 99b1a4e..0934c23 100644 --- a/web-infrastructure/src/main/java/com/zcloud/primeport/plan/HkAccessRecordSyncXxlJob.java +++ b/web-infrastructure/src/main/java/com/zcloud/primeport/plan/HkAccessRecordSyncXxlJob.java @@ -12,6 +12,7 @@ import com.zcloud.gbscommon.hkDevice.HKDeviceUtil; import com.zcloud.primeport.hk.config.HkAccessRecordProperties; import com.zcloud.primeport.persistence.dataobject.HkAccessRecordDO; import com.zcloud.primeport.persistence.repository.HkAccessRecordRepository; +import jodd.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -36,6 +37,7 @@ public class HkAccessRecordSyncXxlJob implements Job { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss+08:00"); private static final DateTimeFormatter PARSE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final int MAX_PAGES = 200; + private static final String ID_CARD_PATTERN = "^(\\d{15}|\\d{17}[0-9Xx])$"; @Override @JobRegister(cron = "0 */5 * * * ?", jobDesc = "海康人员进出记录增量同步", triggerStatus = 1) @@ -120,7 +122,8 @@ public class HkAccessRecordSyncXxlJob implements Job { record.setHkEventId(item.getStr("eventId")); record.setPersonId(item.getStr("personId")); record.setPersonName(item.getStr("personName")); - record.setCertificateNo(item.getStr("certNo")); + String certificateNo = encryptIdCardIfNecessary(item.getStr("certNo")); + record.setCertificateNo(certificateNo); record.setOrgIndexCode(item.getStr("orgIndexCode")); record.setOrgPathName(item.getStr("orgName")); record.setDoorIndexCode(item.getStr("doorIndexCode")); @@ -146,11 +149,38 @@ public class HkAccessRecordSyncXxlJob implements Job { } record.setRecordImageUrl(item.getStr("picUri")); record.setSourceType("PULL"); + item.set("certNo", certificateNo); record.setRawData(item.toString()); record.setLastSyncTime(LocalDateTime.now()); return record; } + private String encryptIdCardIfNecessary(String certNo) { + if (certNo == null || certNo.trim().isEmpty()) { + return certNo; + } + String trimmedCertNo = certNo.trim(); + if (isIdCard(trimmedCertNo)) { + return Base64.encodeToString(trimmedCertNo); + } + if (isEncryptedIdCard(trimmedCertNo)) { + return trimmedCertNo; + } + return certNo; + } + + private boolean isEncryptedIdCard(String certNo) { + try { + return isIdCard(Base64.decodeToString(certNo)); + } catch (Exception e) { + return false; + } + } + + private boolean isIdCard(String certNo) { + return certNo != null && certNo.matches(ID_CARD_PATTERN); + } + private LocalDateTime parseDateTime(String timeStr) { if (timeStr == null || timeStr.isEmpty()) return null; // 兼容 ISO 格式: 2026-08-12T10:30:00+08:00 和普通格式 diff --git a/web-infrastructure/src/main/resources/db/migration/V20260812_01__create_hk_access_record.sql b/web-infrastructure/src/main/resources/db/migration/V20260812_01__create_hk_access_record.sql index 93457ec..dd92583 100644 --- a/web-infrastructure/src/main/resources/db/migration/V20260812_01__create_hk_access_record.sql +++ b/web-infrastructure/src/main/resources/db/migration/V20260812_01__create_hk_access_record.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `hk_access_record` ( `person_id` varchar(128) DEFAULT NULL COMMENT '海康人员ID(personId)', `person_name` varchar(255) DEFAULT NULL COMMENT '人员姓名快照', `certificate_no` varchar(128) DEFAULT NULL COMMENT '证件号码(身份证)', + `phone_no` varchar(128) DEFAULT NULL COMMENT '手机号', `org_index_code` varchar(128) DEFAULT NULL COMMENT '海康组织编码', `org_path_name` varchar(512) DEFAULT NULL COMMENT '海康组织路径名称', `door_index_code` varchar(128) DEFAULT NULL COMMENT '门禁点编码(indexCode)', diff --git a/web-infrastructure/src/main/resources/mapper/ClosedAreaMapper.xml b/web-infrastructure/src/main/resources/mapper/ClosedAreaMapper.xml index 8f50f00..39688e3 100644 --- a/web-infrastructure/src/main/resources/mapper/ClosedAreaMapper.xml +++ b/web-infrastructure/src/main/resources/mapper/ClosedAreaMapper.xml @@ -108,6 +108,37 @@ c.id + + + SELECT pm.finds_staff_no, + pm.finds_staff_name, + pm.finds_terminal_no, + pm.location_status, + pm.finds_device_status, + pm.location_area_id, + pm.location_area_name, + u.id AS user_id, + u.username AS local_staff_no, + u.name AS staff_name, + TRIM(u.phone) AS mobile_no, + u.user_id_card AS id_card_no, + ci.id AS corpinfo_id, + ci.corp_name AS corpinfo_name, + u.department_id, + d.name AS department_name, + t.terminal_no, + t.device_type AS terminal_device_type, + t.device_status AS terminal_device_status, + t.last_location_time AS terminal_last_location_time, + t.last_location_name AS terminal_last_location_name + FROM personnel_match pm + JOIN user_scope_v u ON u.id = pm.local_user_id + LEFT JOIN corp_info ci ON ci.id = u.corpinfo_id + LEFT JOIN department d ON d.id = u.department_id + AND (d.delete_enum IS NULL OR d.delete_enum = 'FALSE') + LEFT JOIN terminal t ON t.terminal_no = pm.finds_terminal_no + AND (t.delete_enum IS NULL OR t.delete_enum = 'FALSE') + WHERE pm.match_status = 'MATCHED' + AND (pm.delete_enum IS NULL OR pm.delete_enum = 'FALSE') + AND (u.delete_enum IS NULL OR u.delete_enum = 'FALSE') + + AND ci.id = #{corpinfoId} + + ORDER BY u.id DESC, pm.finds_staff_no + + + + + + + + diff --git a/web-infrastructure/src/main/resources/mapper/HkAccessRecordMapper.xml b/web-infrastructure/src/main/resources/mapper/HkAccessRecordMapper.xml index cb441bb..0d89781 100644 --- a/web-infrastructure/src/main/resources/mapper/HkAccessRecordMapper.xml +++ b/web-infrastructure/src/main/resources/mapper/HkAccessRecordMapper.xml @@ -17,7 +17,7 @@ id, hk_event_id, person_id, person_name, certificate_no, phone_no, org_index_code, org_path_name, door_index_code, door_name, door_region_index_code, - enter_or_exit, open_result, open_type, card_no, + enter_or_exit, card_no, event_time, hk_create_time, record_image_url, source_type, raw_data, last_sync_time, delete_enum, create_time, update_time, env, version @@ -26,7 +26,7 @@ #{record.certificateNo}, #{record.phoneNo}, #{record.orgIndexCode}, #{record.orgPathName}, #{record.doorIndexCode}, #{record.doorName}, #{record.doorRegionIndexCode}, - #{record.enterOrExit}, #{record.openResult}, #{record.openType}, #{record.cardNo}, + #{record.enterOrExit}, #{record.cardNo}, #{record.eventTime}, #{record.hkCreateTime}, #{record.recordImageUrl}, #{record.sourceType}, #{record.rawData}, #{record.lastSyncTime}, #{record.deleteEnum}, #{record.createTime}, #{record.updateTime}, #{record.env}, #{record.version} @@ -42,8 +42,6 @@ door_name = COALESCE(VALUES(door_name), door_name), door_region_index_code = COALESCE(VALUES(door_region_index_code), door_region_index_code), enter_or_exit = COALESCE(VALUES(enter_or_exit), enter_or_exit), - open_result = COALESCE(VALUES(open_result), open_result), - open_type = COALESCE(VALUES(open_type), open_type), card_no = COALESCE(VALUES(card_no), card_no), event_time = COALESCE(VALUES(event_time), event_time), hk_create_time = COALESCE(VALUES(hk_create_time), hk_create_time),