Pārlūkot izejas kodu

fix(timecard): prevent duplicate approval starts

malk 1 mēnesi atpakaļ
vecāks
revīzija
986e6c5c5e

+ 72 - 10
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -18,9 +18,13 @@ import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * 补发起工时审批(bug 1 兜底)
@@ -43,6 +47,10 @@ import java.util.*;
 @Service
 public class ReSubmitApprovalService {
 
+    // prd 正常 TimeCard 曾在首次审批迅速完成后相隔 77 秒再次发起;保留 3 分钟相同请求结果。
+    private static final long APPROVAL_START_GUARD_MILLIS = 180_000L;
+    private final Map<String, ApprovalStartGuard> approvalStartGuards = new ConcurrentHashMap<>();
+
     @Autowired
     private YDClient ydClient;
 
@@ -452,17 +460,71 @@ public class ReSubmitApprovalService {
 
         Map<String, Object> formData = JSON.parseObject(
                 request.getFormDataJson(), Map.class);
-        List<Map<String, Object>> shards = billable
-                ? splitApprovalStartFormData(formData)
-                : splitOtherApprovalStartFormData(formData);
-        for (Map<String, Object> shard : shards) {
-            startProcessInstance(
-                    request.getFormUuid(),
-                    request.getProcessCode(),
-                    shard,
-                    request.getDeptId());
+        String fillTypeField = whConf.getApprovalFillTypeField();
+        boolean repair = StringUtils.isNotBlank(fillTypeField)
+                && "补填".equals(str(formData, fillTypeField));
+        String guardKey = repair ? null : approvalStartGuardKey(request, formData);
+        ApprovalStartGuard guard = repair ? null : acquireApprovalStartGuard(guardKey);
+        if (guard != null && guard.completed) {
+            log.info("[TimeCard审批发起] 相同请求已成功发起,跳过重复调用");
+            return true;
+        }
+        try {
+            List<Map<String, Object>> shards = billable
+                    ? splitApprovalStartFormData(formData)
+                    : splitOtherApprovalStartFormData(formData);
+            for (Map<String, Object> shard : shards) {
+                startProcessInstance(
+                        request.getFormUuid(),
+                        request.getProcessCode(),
+                        shard,
+                        request.getDeptId());
+            }
+            if (guard != null) guard.complete();
+            return true;
+        } catch (RuntimeException ex) {
+            if (guard != null) approvalStartGuards.remove(guardKey, guard);
+            throw ex;
+        }
+    }
+
+    private String approvalStartGuardKey(TimeCardApprovalStartRequest request, Map<String, Object> formData) {
+        String payload = request.getFormUuid() + "|" + request.getProcessCode() + "|" + JSON.toJSONString(formData);
+        try {
+            byte[] digest = MessageDigest.getInstance("SHA-256")
+                    .digest(payload.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digest.length * 2);
+            for (byte value : digest) hex.append(String.format("%02x", value));
+            return hex.toString();
+        } catch (NoSuchAlgorithmException ex) {
+            throw new IllegalStateException("审批发起幂等键计算失败", ex);
+        }
+    }
+
+    private ApprovalStartGuard acquireApprovalStartGuard(String key) {
+        long now = System.currentTimeMillis();
+        synchronized (approvalStartGuards) {
+            approvalStartGuards.entrySet().removeIf(entry ->
+                    entry.getValue().completed && entry.getValue().expiresAt <= now);
+            ApprovalStartGuard existing = approvalStartGuards.get(key);
+            if (existing != null) {
+                McException.assertAccessException(!existing.completed, "审批正在发起,请勿重复提交");
+                return existing;
+            }
+            ApprovalStartGuard guard = new ApprovalStartGuard();
+            approvalStartGuards.put(key, guard);
+            return guard;
+        }
+    }
+
+    private static final class ApprovalStartGuard {
+        private volatile boolean completed;
+        private volatile long expiresAt;
+
+        private void complete() {
+            expiresAt = System.currentTimeMillis() + APPROVAL_START_GUARD_MILLIS;
+            completed = true;
         }
-        return true;
     }
 
     private List<Map<String, Object>> splitApprovalStartFormData(Map<String, Object> formData) {

+ 69 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java

@@ -17,14 +17,19 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.verify;
 
@@ -97,6 +102,70 @@ class ReSubmitApprovalServiceTest {
         assertEquals("正常", started.get("radioField_fill_type"));
     }
 
+    @Test
+    void shouldNotStartSameNormalApprovalTwiceWithinGuardWindow() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+        whConf.setApprovalFillTypeField("radioField_fill_type");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson("{\"textField_test\":\"same-hours\"}");
+
+        assertTrue(service.startApproval(request));
+        assertTrue(service.startApproval(request));
+        verify(ydClient, times(1)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start));
+
+        request.setFormDataJson("{\"textField_test\":\"changed-hours\"}");
+        assertTrue(service.startApproval(request));
+        verify(ydClient, times(2)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start));
+    }
+
+    @Test
+    void shouldBlockConcurrentNormalApprovalStart() throws Exception {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        when(ydClient.operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start)))
+                .thenAnswer(invocation -> {
+                    entered.countDown();
+                    release.await(5, TimeUnit.SECONDS);
+                    return null;
+                });
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson("{\"textField_test\":\"same-hours\"}");
+
+        CompletableFuture<Boolean> first = CompletableFuture.supplyAsync(() -> service.startApproval(request));
+        try {
+            assertTrue(entered.await(5, TimeUnit.SECONDS));
+            assertThrows(RuntimeException.class, () -> service.startApproval(request));
+        } finally {
+            release.countDown();
+        }
+        assertTrue(first.get(5, TimeUnit.SECONDS));
+        verify(ydClient, times(1)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start));
+    }
+
     @Test
     void shouldKeepExplicitRepairFillTypeWhenStartingApproval() {
         YDClient ydClient = mock(YDClient.class);