feat(): 通过人员定位系统统计封闭区域人员车辆数据

dev_km_logs
lishiwei 2026-08-21 11:16:14 +08:00
parent 5483a9efa5
commit 4b7358ab1d
20 changed files with 1097 additions and 19 deletions

View File

@ -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<ClosedAreaCorpStatCO> corpStat( @RequestBody ClosedAreaCorpStatCmd cmd) {

View File

@ -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<AreaPolygon> prepare(List<ClosedAreaCountDO> areas) {
if (areas == null || areas.isEmpty()) {
return Collections.emptyList();
}
List<AreaPolygon> 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<AreaPolygon> 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<BigDecimal[]> parseLocation(ClosedAreaCountDO area) {
List<BigDecimal[]> 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<BigDecimal[]> points;
private AreaPolygon(ClosedAreaCountDO area, List<BigDecimal[]> points) {
this.area = area;
this.points = points;
}
ClosedAreaCountDO getArea() {
return area;
}
}
}

View File

@ -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;
}

View File

@ -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<ClosedAreaPersonLocation> listOnlinePersons(Long corpinfoId) {
if (corpinfoId == null) {
return Collections.emptyList();
}
List<ClosedAreaPersonLocationDO> matched = locationMapper.listMatchedPersons(corpinfoId);
if (matched == null || matched.isEmpty()) {
return Collections.emptyList();
}
fillMissingTerminalNos(matched);
Map<String, ClosedAreaPersonLocationDO> terminals = loadTerminals(matched);
Set<String> terminalNosToLocate = collectOnlineTerminalNos(matched, terminals);
Map<String, JsonNode> currentLocations = locateTerminals(terminalNosToLocate);
List<ClosedAreaPersonLocation> result = new ArrayList<>();
Set<Long> 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<ClosedAreaPersonLocationDO> matched) {
Set<String> 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<String, String> terminalNosByIdCardNo = new LinkedHashMap<>();
for (List<String> 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<String, ClosedAreaPersonLocationDO> loadTerminals(List<ClosedAreaPersonLocationDO> matched) {
Set<String> terminalNos = new LinkedHashSet<>();
Map<String, ClosedAreaPersonLocationDO> 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<String> batch : batches(terminalNos)) {
for (ClosedAreaPersonLocationDO terminal : locationMapper.listTerminals(batch)) {
if (StringUtils.hasText(terminal.getTerminalNo())) {
result.putIfAbsent(terminal.getTerminalNo(), terminal);
}
}
}
return result;
}
private Set<String> collectOnlineTerminalNos(List<ClosedAreaPersonLocationDO> matched,
Map<String, ClosedAreaPersonLocationDO> terminals) {
Set<String> 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<String, JsonNode> locateTerminals(Set<String> terminalNos) {
if (terminalNos == null || terminalNos.isEmpty()) {
return Collections.emptyMap();
}
List<List<String>> batches = batches(terminalNos);
List<Map<String, Object>> requests = new ArrayList<>(batches.size());
for (List<String> batch : batches) {
requests.add(Collections.singletonMap("terminalNoList", batch));
}
List<JsonNode> roots = findsOpenApiClient.postFailFastBatch(
POINT_LOCATE_API, requests, FINDS_BATCH_CONCURRENCY);
Map<String, JsonNode> 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<String> terminalNos, JsonNode root, Map<String, JsonNode> 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<List<String>> batches(Set<String> values) {
List<String> normalized = new ArrayList<>();
if (values != null) {
for (String value : values) {
String item = normalize(value);
if (StringUtils.hasText(item)) {
normalized.add(item);
}
}
}
List<List<String>> 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;
}
}

View File

@ -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<ClosedAreaCountCO> execute(ClosedAreaCountCmd cmd) {
Map<String, Object> params = PageQueryHelper.toHashMap(cmd);
List<ClosedAreaCountDO> rows = closedAreaRepository.listPositionCount(params);
fillRealtimeCounts(rows, cmd);
List<ClosedAreaCountCO> 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<ClosedAreaCountDO> areas, ClosedAreaCountCmd cmd) {
if (areas == null || areas.isEmpty()) {
return;
}
Map<Long, List<ClosedAreaCountDO>> areasByCorpinfoId = groupAreasByCorpinfoId(areas, cmd);
for (Map.Entry<Long, List<ClosedAreaCountDO>> entry : areasByCorpinfoId.entrySet()) {
List<ClosedAreaMatcher.AreaPolygon> polygons = closedAreaMatcher.prepare(entry.getValue());
if (polygons.isEmpty()) {
continue;
}
List<ClosedAreaPersonLocation> persons = personLocationProvider.listOnlinePersons(entry.getKey());
Map<Long, Integer> 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<Long, List<ClosedAreaCountDO>> groupAreasByCorpinfoId(List<ClosedAreaCountDO> areas,
ClosedAreaCountCmd cmd) {
Map<Long, List<ClosedAreaCountDO>> 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<Long, Integer> countPersonsByArea(List<ClosedAreaPersonLocation> persons,
List<ClosedAreaMatcher.AreaPolygon> polygons) {
if (persons == null || persons.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, Integer> 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<ClosedAreaCountCO> treeChildren(Long parentId, List<ClosedAreaCountDO> rows) {
List<ClosedAreaCountCO> 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<ClosedAreaCountCO> 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);
}
}

View File

@ -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<JsonNode> postFailFastBatch(String apiName, List<Map<String, Object>> 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<JsonNode> result = new ArrayList<>(requests.size());
for (Map<String, Object> request : requests) {
result.add(post(config, apiName, request));
}
return result;
}
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
try {
List<Future<JsonNode>> futures = new ArrayList<>(requests.size());
for (Map<String, Object> request : requests) {
futures.add(executor.submit(() -> post(config, apiName, request)));
}
List<JsonNode> result = new ArrayList<>(requests.size());
for (Future<JsonNode> 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<String, Object> 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;
}
}

View File

@ -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<ClosedAreaCountCO> positionCount(ClosedAreaCountCmd cmd) {
return closedAreaPositionCountExe.execute(cmd);
}
@Override
public SingleResponse<ClosedAreaCorpStatCO> corpStat(ClosedAreaCorpStatCmd cmd) {
return closedAreaCountExe.corpStat(cmd);

View File

@ -28,6 +28,8 @@ public interface ClosedAreaServiceI {
MultiResponse<ClosedAreaCountCO> count(ClosedAreaCountCmd cmd);
MultiResponse<ClosedAreaCountCO> positionCount(ClosedAreaCountCmd cmd);
SingleResponse<ClosedAreaCorpStatCO> corpStat(ClosedAreaCorpStatCmd cmd);
void remove(Long id);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ClosedAreaDO> {
List<ClosedAreaCountDO> listCount(@Param("params") Map<String,Object> params);
List<ClosedAreaCountDO> listPositionCount(@Param("params") Map<String,Object> params);
ClosedAreaCorpStatDO corpStat(@Param("params") Map<String, Object> params);
List<ClosedAreaDO> getCountByHgAuthArea();

View File

@ -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<ClosedAreaPersonLocationDO> listMatchedPersons(@Param("corpinfoId") Long corpinfoId);
List<ClosedAreaTerminalBindingDO> listTerminalBindingsByIdCardNos(@Param("values") List<String> values);
List<ClosedAreaPersonLocationDO> listTerminals(@Param("values") List<String> values);
OpenapiConfigDO findEnabledOpenapiConfig(@Param("corpinfoId") Long corpinfoId);
}

View File

@ -21,6 +21,8 @@ public interface ClosedAreaRepository extends BaseRepository<ClosedAreaDO> {
List<ClosedAreaCountDO> listCount(Map<String,Object> params);
List<ClosedAreaCountDO> listPositionCount(Map<String,Object> params);
ClosedAreaCorpStatDO corpStat(Map<String, Object> params);
List<ClosedAreaDO> getCountByHgAuthArea();

View File

@ -45,6 +45,11 @@ public class ClosedAreaRepositoryImpl extends BaseRepositoryImpl<ClosedAreaMappe
return closedAreaMapper.listCount(params);
}
@Override
public List<ClosedAreaCountDO> listPositionCount(Map<String, Object> params) {
return closedAreaMapper.listPositionCount(params);
}
@Override
public ClosedAreaCorpStatDO corpStat(Map<String, Object> params) {
return closedAreaMapper.corpStat(params);

View File

@ -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 和普通格式

View File

@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `hk_access_record` (
`person_id` varchar(128) DEFAULT NULL COMMENT '海康人员IDpersonId',
`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',

View File

@ -108,6 +108,37 @@
c.id
</select>
<select id="listPositionCount" resultType="com.zcloud.primeport.persistence.dataobject.ClosedAreaCountDO">
SELECT
c.*,
CASE WHEN child_cnt > 0 THEN 1 ELSE 2 END AS has_child,
0 AS person_count,
0 AS car_count
FROM
closed_area c
LEFT JOIN (
SELECT parent_id, COUNT(id) child_cnt
FROM closed_area
WHERE delete_enum = 'FALSE'
GROUP BY parent_id
) child ON c.id = child.parent_id
WHERE
c.delete_enum = 'FALSE'
<if test="params.corpId != null">
AND c.jurisdictional_corp_id = #{params.corpId}
</if>
<if test="params.parentId != null">
AND c.parent_id = #{params.parentId}
</if>
<if test="params.closedAreaName != null and params.closedAreaName != ''">
AND C.closed_area_name LIKE CONCAT('%', #{params.closedAreaName}, '%')
</if>
GROUP BY
c.id
ORDER BY
c.id
</select>
<select id="corpStat" resultType="com.zcloud.primeport.persistence.dataobject.ClosedAreaCorpStatDO">
SELECT
IFNULL(COUNT(DISTINCT ca.id), 0) AS closed_area_count,
@ -177,17 +208,33 @@
<if test="params.vehiclePlateNumber != null and params.vehiclePlateNumber != ''">
AND hvar.plate_no LIKE CONCAT('%', #{params.vehiclePlateNumber}, '%')
</if>
<if test="params.arrivalStartTime != null and params.arrivalStartTime != ''">
AND hvar.event_time &gt;= CONCAT(#{params.arrivalStartTime}, ' 00:00:00')
</if>
<if test="params.arrivalEndTime != null and params.arrivalEndTime != ''">
AND hvar.event_time &lt;= CONCAT(#{params.arrivalEndTime}, ' 23:59:59')
</if>
<if test="params.departureStartTime != null and params.departureStartTime != ''">
AND hvar.event_time &gt;= CONCAT(#{params.departureStartTime}, ' 00:00:00')
</if>
<if test="params.departureEndTime != null and params.departureEndTime != ''">
AND hvar.event_time &lt;= CONCAT(#{params.departureEndTime}, ' 23:59:59')
<if test="(params.arrivalStartTime != null and params.arrivalStartTime != '') or (params.arrivalEndTime != null and params.arrivalEndTime != '') or (params.departureStartTime != null and params.departureStartTime != '') or (params.departureEndTime != null and params.departureEndTime != '')">
AND (
<trim prefixOverrides="OR">
<if test="(params.arrivalStartTime != null and params.arrivalStartTime != '') or (params.arrivalEndTime != null and params.arrivalEndTime != '')">
OR (
hvar.enter_or_exit = 0
<if test="params.arrivalStartTime != null and params.arrivalStartTime != ''">
AND hvar.event_time &gt;= CONCAT(#{params.arrivalStartTime}, ' 00:00:00')
</if>
<if test="params.arrivalEndTime != null and params.arrivalEndTime != ''">
AND hvar.event_time &lt;= CONCAT(#{params.arrivalEndTime}, ' 23:59:59')
</if>
)
</if>
<if test="(params.departureStartTime != null and params.departureStartTime != '') or (params.departureEndTime != null and params.departureEndTime != '')">
OR (
hvar.enter_or_exit = 1
<if test="params.departureStartTime != null and params.departureStartTime != ''">
AND hvar.event_time &gt;= CONCAT(#{params.departureStartTime}, ' 00:00:00')
</if>
<if test="params.departureEndTime != null and params.departureEndTime != ''">
AND hvar.event_time &lt;= CONCAT(#{params.departureEndTime}, ' 23:59:59')
</if>
)
</if>
</trim>
)
</if>
</sql>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zcloud.primeport.persistence.mapper.ClosedAreaPersonLocationMapper">
<select id="listMatchedPersons" resultType="com.zcloud.primeport.persistence.dataobject.ClosedAreaPersonLocationDO">
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')
<if test="corpinfoId != null">
AND ci.id = #{corpinfoId}
</if>
ORDER BY u.id DESC, pm.finds_staff_no
</select>
<select id="listTerminalBindingsByIdCardNos"
resultType="com.zcloud.primeport.persistence.dataobject.ClosedAreaTerminalBindingDO">
SELECT tb.id_card_no AS binding_id_card_no,
t.bind_id_card_no AS terminal_id_card_no,
t.terminal_no
FROM terminal t
LEFT JOIN terminal_bind tb ON tb.id = (
SELECT tb2.id FROM terminal_bind tb2 WHERE tb2.terminal_no = t.terminal_no
AND tb2.bind_status = 'BOUND'
AND (tb2.delete_enum IS NULL OR tb2.delete_enum = 'FALSE')
ORDER BY tb2.bind_time DESC, tb2.id DESC LIMIT 1)
WHERE (t.delete_enum IS NULL OR t.delete_enum = 'FALSE')
<choose>
<when test="values != null and values.size() > 0">
AND (
tb.id_card_no IN
<foreach collection="values" item="value" open="(" separator="," close=")">
#{value}
</foreach>
OR (
(tb.id_card_no IS NULL OR tb.id_card_no = '')
AND t.bind_id_card_no IN
<foreach collection="values" item="value" open="(" separator="," close=")">
#{value}
</foreach>
)
)
</when>
<otherwise>
AND 1 = 0
</otherwise>
</choose>
ORDER BY binding_id_card_no, terminal_id_card_no, t.update_time DESC, t.id DESC
</select>
<select id="listTerminals" resultType="com.zcloud.primeport.persistence.dataobject.ClosedAreaPersonLocationDO">
SELECT terminal_no,
device_type AS terminal_device_type,
device_status AS terminal_device_status,
last_location_time AS terminal_last_location_time,
last_location_name AS terminal_last_location_name
FROM terminal
WHERE (delete_enum IS NULL OR delete_enum = 'FALSE')
AND terminal_no IN
<foreach collection="values" item="value" open="(" separator="," close=")">#{value}</foreach>
ORDER BY update_time DESC, id DESC
</select>
<select id="findEnabledOpenapiConfig" resultType="com.zcloud.primeport.persistence.dataobject.OpenapiConfigDO">
SELECT *
FROM openapi_config
WHERE enabled = 1
AND (delete_enum IS NULL OR delete_enum = 'FALSE')
AND (corpinfo_id = #{corpinfoId} OR corpinfo_id IS NULL)
ORDER BY CASE WHEN corpinfo_id = #{corpinfoId} THEN 0 ELSE 1 END, id DESC
LIMIT 1
</select>
</mapper>

View File

@ -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),