|
|
@@ -0,0 +1,332 @@
|
|
|
+package org.jeecg.modules.app.service;
|
|
|
+
|
|
|
+import cn.hutool.core.collection.CollUtil;
|
|
|
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|
|
+import com.google.gson.JsonObject;
|
|
|
+import com.google.gson.JsonParser;
|
|
|
+import lombok.Getter;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.jeecg.common.constant.CommonConstant;
|
|
|
+import org.jeecg.common.exception.JeecgBootException;
|
|
|
+import org.jeecg.modules.system.app.entity.AppDevice;
|
|
|
+import org.jeecg.modules.system.app.entity.AppOrderProInfo;
|
|
|
+import org.jeecg.modules.system.app.entity.AppSitePriceRules;
|
|
|
+import org.jeecg.modules.system.app.entity.FamilyMembers;
|
|
|
+import org.jeecg.modules.system.app.mapper.AppDeviceMapper;
|
|
|
+import org.jeecg.modules.system.app.mapper.AppOrderProInfoMapper;
|
|
|
+import org.jeecg.modules.system.app.mapper.FamilyMembersMapper;
|
|
|
+import org.springframework.beans.factory.annotation.Qualifier;
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+import org.springframework.transaction.support.TransactionSynchronization;
|
|
|
+import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.LocalTime;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Objects;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.function.Supplier;
|
|
|
+
|
|
|
+import static org.jeecg.modules.hikiot.HikiotTool.addFace;
|
|
|
+import static org.jeecg.modules.hikiot.HikiotTool.addUser;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Keeps field-order access permissions consistent between the order database
|
|
|
+ * and Hikvision devices.
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class FieldAccessPermissionService {
|
|
|
+
|
|
|
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
|
|
|
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
|
|
+ private static final int MAX_HIKIOT_ATTEMPTS = 3;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AppOrderProInfoMapper appOrderProInfoMapper;
|
|
|
+ @Resource
|
|
|
+ private FamilyMembersMapper familyMembersMapper;
|
|
|
+ @Resource
|
|
|
+ private AppDeviceMapper appDeviceMapper;
|
|
|
+ @Resource
|
|
|
+ private FaceImageNormalizationService faceImageNormalizationService;
|
|
|
+ @Resource
|
|
|
+ @Qualifier("ioIntensiveTaskExecutor")
|
|
|
+ private ThreadPoolTaskExecutor ioIntensiveTaskExecutor;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Rebuilds one person's permission for one site. Pending orders and a
|
|
|
+ * same-day used order are both retained so a successful entry never removes
|
|
|
+ * the permission needed to leave the school.
|
|
|
+ */
|
|
|
+ public void synchronizeFamilySite(String familyId, String siteId) {
|
|
|
+ if (StringUtils.isBlank(familyId) || StringUtils.isBlank(siteId)) {
|
|
|
+ throw new JeecgBootException("门禁权限同步参数不完整");
|
|
|
+ }
|
|
|
+
|
|
|
+ List<AppOrderProInfo> activeOrders = appOrderProInfoMapper.selectList(
|
|
|
+ Wrappers.<AppOrderProInfo>lambdaQuery()
|
|
|
+ .eq(AppOrderProInfo::getFamilyUserId, familyId)
|
|
|
+ .eq(AppOrderProInfo::getSiteId, siteId)
|
|
|
+ .eq(AppOrderProInfo::getType, CommonConstant.ORDER_PRO_INFO_TYPE_0)
|
|
|
+ .in(AppOrderProInfo::getOrderStatus,
|
|
|
+ CommonConstant.ORDER_STATUS_1, CommonConstant.ORDER_STATUS_2)
|
|
|
+ .ge(AppOrderProInfo::getUseDateStr, LocalDate.now().format(DATE_FORMATTER))
|
|
|
+ .isNotNull(AppOrderProInfo::getUseDateStr)
|
|
|
+ .isNotNull(AppOrderProInfo::getFrameTimeStr));
|
|
|
+ AccessWindow accessWindow = buildAccessWindow(activeOrders);
|
|
|
+ if (accessWindow == null) {
|
|
|
+ throw new JeecgBootException("未找到有效的场地订单门禁时段");
|
|
|
+ }
|
|
|
+
|
|
|
+ FamilyMembers familyMember = familyMembersMapper.selectById(familyId);
|
|
|
+ if (familyMember == null) {
|
|
|
+ throw new JeecgBootException("订单使用人不存在");
|
|
|
+ }
|
|
|
+ String faceUrl = faceImageNormalizationService.ensureNormalized(familyMember);
|
|
|
+ List<AppDevice> devices = appDeviceMapper.selectList(
|
|
|
+ Wrappers.<AppDevice>lambdaQuery().eq(AppDevice::getSiteId, siteId));
|
|
|
+ if (CollUtil.isEmpty(devices)) {
|
|
|
+ throw new JeecgBootException("场地未配置门禁设备,请联系管理员");
|
|
|
+ }
|
|
|
+
|
|
|
+ Date beginDate = Date.from(accessWindow.getBeginDate().atTime(12, 0)
|
|
|
+ .atZone(ZoneId.systemDefault()).toInstant());
|
|
|
+ Date endDate = Date.from(accessWindow.getEndDate().atTime(12, 0)
|
|
|
+ .atZone(ZoneId.systemDefault()).toInstant());
|
|
|
+ String frameTime = accessWindow.getBeginTime().format(TIME_FORMATTER)
|
|
|
+ + "-" + accessWindow.getEndTime().format(TIME_FORMATTER);
|
|
|
+
|
|
|
+ for (AppDevice device : devices) {
|
|
|
+ if (device == null || StringUtils.isBlank(device.getDeviceSerial())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String serial = device.getDeviceSerial();
|
|
|
+ invokeHikiotWithRetry("用户权限", serial,
|
|
|
+ () -> addUser(beginDate, serial, familyMember.getFullName(), familyId, endDate, frameTime));
|
|
|
+ invokeHikiotWithRetry("人脸", serial, () -> addFace(serial, familyId, faceUrl));
|
|
|
+ }
|
|
|
+ log.info("场地门禁权限同步完成,siteId={}, familyId={}, beginDate={}, endDate={}, frameTime={}, deviceCount={}",
|
|
|
+ siteId, familyId, accessWindow.getBeginDate(), accessWindow.getEndDate(), frameTime, devices.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Applies changed school opening hours to already-created active orders and
|
|
|
+ * republishes affected permissions after the surrounding transaction commits.
|
|
|
+ */
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
+ public int updateFutureOrderSlotsAndQueue(String siteId, Collection<AppSitePriceRules> priceRules) {
|
|
|
+ if (StringUtils.isBlank(siteId) || CollUtil.isEmpty(priceRules)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, List<String>> framesByDate = new HashMap<>();
|
|
|
+ for (AppSitePriceRules rule : priceRules) {
|
|
|
+ if (rule == null || rule.getDateOfSale() == null || rule.getStartTime() == null || rule.getEndTime() == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String date = rule.getDateOfSale().toInstant().atZone(ZoneId.systemDefault())
|
|
|
+ .toLocalDate().format(DATE_FORMATTER);
|
|
|
+ String frame = rule.getStartTime().toInstant().atZone(ZoneId.systemDefault())
|
|
|
+ .toLocalTime().format(TIME_FORMATTER)
|
|
|
+ + "-" + rule.getEndTime().toInstant().atZone(ZoneId.systemDefault())
|
|
|
+ .toLocalTime().format(TIME_FORMATTER);
|
|
|
+ framesByDate.computeIfAbsent(date, ignored -> new ArrayList<>()).add(frame);
|
|
|
+ }
|
|
|
+ if (framesByDate.isEmpty()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<AppOrderProInfo> activeOrders = appOrderProInfoMapper.selectList(
|
|
|
+ Wrappers.<AppOrderProInfo>lambdaQuery()
|
|
|
+ .eq(AppOrderProInfo::getSiteId, siteId)
|
|
|
+ .eq(AppOrderProInfo::getType, CommonConstant.ORDER_PRO_INFO_TYPE_0)
|
|
|
+ .in(AppOrderProInfo::getOrderStatus,
|
|
|
+ CommonConstant.ORDER_STATUS_1, CommonConstant.ORDER_STATUS_2)
|
|
|
+ .ge(AppOrderProInfo::getUseDateStr, LocalDate.now().format(DATE_FORMATTER))
|
|
|
+ .in(AppOrderProInfo::getUseDateStr, framesByDate.keySet()));
|
|
|
+
|
|
|
+ Set<String> affectedFamilies = new HashSet<>();
|
|
|
+ int updated = 0;
|
|
|
+ for (AppOrderProInfo order : activeOrders) {
|
|
|
+ List<String> candidates = framesByDate.get(order.getUseDateStr());
|
|
|
+ String newFrame = selectClosestFrame(order.getFrameTimeStr(), candidates);
|
|
|
+ if (StringUtils.isBlank(newFrame)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ affectedFamilies.add(order.getFamilyUserId());
|
|
|
+ if (!Objects.equals(order.getFrameTimeStr(), newFrame)) {
|
|
|
+ order.setFrameTimeStr(newFrame);
|
|
|
+ order.setExpireTime(order.getUseDateStr() + " " + newFrame.substring(newFrame.indexOf('-') + 1));
|
|
|
+ order.setUpdateTime(new Date());
|
|
|
+ updated += appOrderProInfoMapper.updateById(order);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ queueAfterCommit(siteId, affectedFamilies);
|
|
|
+ log.info("学校开放时段变更已同步到存量订单,siteId={}, matchedOrders={}, updatedOrders={}, families={}",
|
|
|
+ siteId, activeOrders.size(), updated, affectedFamilies.size());
|
|
|
+ return updated;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void queueAfterCommit(String siteId, Set<String> familyIds) {
|
|
|
+ if (CollUtil.isEmpty(familyIds)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Set<String> snapshot = new HashSet<>(familyIds);
|
|
|
+ Runnable task = () -> {
|
|
|
+ for (String familyId : snapshot) {
|
|
|
+ try {
|
|
|
+ synchronizeFamilySite(familyId, siteId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("开放时段变更后门禁权限重推失败,siteId={}, familyId={}", siteId, familyId, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ if (TransactionSynchronizationManager.isSynchronizationActive()
|
|
|
+ && TransactionSynchronizationManager.isActualTransactionActive()) {
|
|
|
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
|
+ @Override
|
|
|
+ public void afterCommit() {
|
|
|
+ ioIntensiveTaskExecutor.execute(task);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ ioIntensiveTaskExecutor.execute(task);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static AccessWindow buildAccessWindow(List<AppOrderProInfo> orders) {
|
|
|
+ if (CollUtil.isEmpty(orders)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ LocalDate beginDate = null;
|
|
|
+ LocalDate endDate = null;
|
|
|
+ LocalTime beginTime = null;
|
|
|
+ LocalTime endTime = null;
|
|
|
+ for (AppOrderProInfo order : orders) {
|
|
|
+ try {
|
|
|
+ LocalDate date = LocalDate.parse(order.getUseDateStr(), DATE_FORMATTER);
|
|
|
+ String[] times = StringUtils.split(order.getFrameTimeStr(), '-');
|
|
|
+ if (times == null || times.length != 2) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ LocalTime start = LocalTime.parse(times[0], TIME_FORMATTER);
|
|
|
+ LocalTime end = LocalTime.parse(times[1], TIME_FORMATTER);
|
|
|
+ if (end.isBefore(start)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ beginDate = beginDate == null || date.isBefore(beginDate) ? date : beginDate;
|
|
|
+ endDate = endDate == null || date.isAfter(endDate) ? date : endDate;
|
|
|
+ beginTime = beginTime == null || start.isBefore(beginTime) ? start : beginTime;
|
|
|
+ endTime = endTime == null || end.isAfter(endTime) ? end : endTime;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("忽略无法解析的场地订单门禁时段,orderId={}, useDateStr={}, frameTimeStr={}",
|
|
|
+ order.getId(), order.getUseDateStr(), order.getFrameTimeStr());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (beginDate == null || endDate == null || beginTime == null || endTime == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return new AccessWindow(beginDate, endDate, beginTime, endTime);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String selectClosestFrame(String currentFrame, List<String> candidates) {
|
|
|
+ if (CollUtil.isEmpty(candidates)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (candidates.contains(currentFrame)) {
|
|
|
+ return currentFrame;
|
|
|
+ }
|
|
|
+ if (candidates.size() == 1) {
|
|
|
+ return candidates.get(0);
|
|
|
+ }
|
|
|
+ long bestDistance = Long.MAX_VALUE;
|
|
|
+ String best = candidates.get(0);
|
|
|
+ LocalTime[] current = parseFrame(currentFrame);
|
|
|
+ if (current == null) {
|
|
|
+ return best;
|
|
|
+ }
|
|
|
+ for (String candidate : candidates) {
|
|
|
+ LocalTime[] parsed = parseFrame(candidate);
|
|
|
+ if (parsed == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ long distance = Math.abs(current[0].toSecondOfDay() - parsed[0].toSecondOfDay())
|
|
|
+ + Math.abs(current[1].toSecondOfDay() - parsed[1].toSecondOfDay());
|
|
|
+ if (distance < bestDistance) {
|
|
|
+ bestDistance = distance;
|
|
|
+ best = candidate;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return best;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LocalTime[] parseFrame(String frame) {
|
|
|
+ try {
|
|
|
+ String[] values = StringUtils.split(frame, '-');
|
|
|
+ if (values == null || values.length != 2) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return new LocalTime[]{LocalTime.parse(values[0], TIME_FORMATTER),
|
|
|
+ LocalTime.parse(values[1], TIME_FORMATTER)};
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void invokeHikiotWithRetry(String operation, String deviceSerial, Supplier<String> request) {
|
|
|
+ RuntimeException lastException = null;
|
|
|
+ for (int attempt = 1; attempt <= MAX_HIKIOT_ATTEMPTS; attempt++) {
|
|
|
+ try {
|
|
|
+ String response = request.get();
|
|
|
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
|
|
+ if (json.has("code") && json.get("code").getAsInt() == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String message = json.has("msg") ? json.get("msg").getAsString() : "未知错误";
|
|
|
+ lastException = new JeecgBootException(operation + "下发失败: " + message);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ lastException = e;
|
|
|
+ }
|
|
|
+ if (attempt < MAX_HIKIOT_ATTEMPTS) {
|
|
|
+ try {
|
|
|
+ Thread.sleep(300L * attempt);
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ throw new JeecgBootException(operation + "下发被中断", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.error("海康{}下发失败,deviceSerial={},已重试{}次", operation, deviceSerial, MAX_HIKIOT_ATTEMPTS,
|
|
|
+ lastException);
|
|
|
+ throw new JeecgBootException("设备" + operation + "录入失败,请联系管理员", lastException);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Getter
|
|
|
+ static class AccessWindow {
|
|
|
+ private final LocalDate beginDate;
|
|
|
+ private final LocalDate endDate;
|
|
|
+ private final LocalTime beginTime;
|
|
|
+ private final LocalTime endTime;
|
|
|
+
|
|
|
+ AccessWindow(LocalDate beginDate, LocalDate endDate, LocalTime beginTime, LocalTime endTime) {
|
|
|
+ this.beginDate = beginDate;
|
|
|
+ this.endDate = endDate;
|
|
|
+ this.beginTime = beginTime;
|
|
|
+ this.endTime = endTime;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|