Преглед изворни кода

fix(timecard): 补齐审批分片与请求超时保护

malk пре 2 месеци
родитељ
комит
6e21c25c1d

+ 124 - 33
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -52,6 +52,16 @@ public class ReSubmitApprovalService {
      */
     private static final String APPROVAL_OPINION_AGREE = "同意";
 
+    /**
+     * 宜搭单个子表组件可稳定查询和覆盖更新的最大行数。
+     */
+    private static final int MAX_APPROVAL_SUBTABLE_ROWS = 500;
+
+    /**
+     * 批量历史补偿的目标分片大小;以员工整组为边界,允许单个员工整组略超目标值。
+     */
+    private static final int TARGET_APPROVAL_SUBTABLE_ROWS = 100;
+
     /**
      * 兜底部门主管 userId(Raymond,前端 v2.42 保持一致)
      */
@@ -651,31 +661,63 @@ public class ReSubmitApprovalService {
         searchFieldJson.put(A_MANAGER, Collections.singletonList(mg.managerId));
 
         List<Map> running = queryProcessRunning(whConf.getFormUuidApproval(), searchFieldJson);
-        if (running.isEmpty()) {
-            createApprovalInstance(mg, monthText, submitterUid);
-            return "created";
-        }
-        // 取第一条 RUNNING
-        Map first = running.get(0);
-        String pid = str(first, "processInstanceId");
-        String finstId = firstNonBlank(str(first, "formInstanceId"), pid);
-        Map existingFormData = (Map) first.get("formData");
-        // formData 未直接返回时,回查详情
-        if (existingFormData == null || existingFormData.isEmpty()) {
-            existingFormData = ydClient.queryData(YDParam.builder()
-                    .appType(whConf.getYidaAppType())
-                    .systemToken(whConf.getYidaSystemToken())
-                    .formInstanceId(finstId)
-                    .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+        // 优先追加到最新分片,避免每名员工都先扫描已满 500 行的旧审批单。
+        running.sort(Comparator.comparingLong(
+                (Map item) -> numLong(item, "gmtModified")).reversed());
+        for (Map item : running) {
+            String pid = str(item, "processInstanceId");
+            String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
+            Map existingFormData = processFormData(item, finstId);
+            if (!managerApprovalHasCapacity(finstId, mg, submitterUid, monthText, existingFormData)) {
+                continue;
+            }
+            updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData);
+            return "updated";
         }
-        updateApprovalInstance(finstId, mg, monthText, submitterUid, existingFormData);
-        return "updated";
+        // fixme: 单张审批子表达到 500 行后必须新建分片,继续覆盖会丢失第 501 行后的明细。
+        createApprovalInstance(mg, monthText, submitterUid);
+        return "created";
+    }
+
+    private boolean managerApprovalHasCapacity(String formInstanceId,
+                                               ManagerGroup mg,
+                                               String submitterUid,
+                                               String monthText,
+                                               Map existingFormData) {
+        List<Map> existBil = resolveFullRows(
+                formInstanceId,
+                whConf.getFormUuidApproval(),
+                A_BIL_TABLE,
+                (List<Map>) (existingFormData == null ? null : existingFormData.get(A_BIL_TABLE)));
+        List<Map> existNon = resolveFullRows(
+                formInstanceId,
+                whConf.getFormUuidApproval(),
+                A_NON_TABLE,
+                (List<Map>) (existingFormData == null ? null : existingFormData.get(A_NON_TABLE)));
+        MergeResult billable = mergeSubRows(
+                existBil, mg.billableRows, submitterUid, monthText,
+                A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE,
+                br -> buildBillableApprovalRow(br, submitterUid));
+        MergeResult nonBillable = mergeSubRows(
+                existNon, mg.nonBillableRows, submitterUid, monthText,
+                A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE,
+                br -> buildNonBillableApprovalRow(br, submitterUid));
+        int existingBillableCount = countUniqueRows(
+                existBil, A_BIL_SUBMITTER_UID, A_BIL_DAY_TEXT, A_BIL_PROJECT_CODE);
+        int existingNonBillableCount = countUniqueRows(
+                existNon, A_NON_SUBMITTER_UID, A_NON_DAY_TEXT, A_NON_PROJECT_CODE);
+        return canAppendToApprovalShard(existingBillableCount, billable.rows.size())
+                && canAppendToApprovalShard(existingNonBillableCount, nonBillable.rows.size());
     }
 
     /**
      * 创建新的工时审批实例(startProcessInstance)
      */
     private void createApprovalInstance(ManagerGroup mg, String monthText, String submitterUid) {
+        McException.assertAccessException(
+                !hasSubtableCapacity(mg.billableRows.size())
+                        || !hasSubtableCapacity(mg.nonBillableRows.size()),
+                "单个员工的审批明细超过宜搭子表 500 行限制");
         Map<String, Object> formData = new HashMap<>();
         formData.put(A_MONTH_DATE, monthToTs(monthText));
         formData.put(A_MONTH_TEXT, monthText);
@@ -803,27 +845,49 @@ public class ReSubmitApprovalService {
         searchFieldJson.put(O_DEPT_MANAGER, Collections.singletonList(deptManagerId));
 
         List<Map> running = queryProcessRunning(whConf.getFormUuidOtherApproval(), searchFieldJson);
-        if (running.isEmpty()) {
-            createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid);
-            return "created";
+        running.sort(Comparator.comparingLong(
+                (Map item) -> numLong(item, "gmtModified")).reversed());
+        for (Map item : running) {
+            String pid = str(item, "processInstanceId");
+            String finstId = firstNonBlank(str(item, "formInstanceId"), pid);
+            Map existingFormData = processFormData(item, finstId);
+            if (!otherApprovalHasCapacity(finstId, otherRows, submitterUid, existingFormData)) {
+                continue;
+            }
+            updateOtherApprovalInstance(finstId, otherRows, monthText, submitterUid, existingFormData);
+            return "updated";
         }
-        Map first = running.get(0);
-        String pid = str(first, "processInstanceId");
-        String finstId = firstNonBlank(str(first, "formInstanceId"), pid);
-        Map existingFormData = (Map) first.get("formData");
-        if (existingFormData == null || existingFormData.isEmpty()) {
-            existingFormData = ydClient.queryData(YDParam.builder()
-                    .appType(whConf.getYidaAppType())
-                    .systemToken(whConf.getYidaSystemToken())
-                    .formInstanceId(finstId)
-                    .build(), YDConf.FORM_QUERY.retrieve_id).getFormData();
+        createOtherApprovalInstance(otherRows, deptManagerId, monthText, submitterUid);
+        return "created";
+    }
+
+    private boolean otherApprovalHasCapacity(String formInstanceId,
+                                             List<OtherRow> otherRows,
+                                             String submitterUid,
+                                             Map existingFormData) {
+        List<Map> existing = resolveFullRows(
+                formInstanceId,
+                whConf.getFormUuidOtherApproval(),
+                O_SUB_TABLE,
+                (List<Map>) (existingFormData == null ? null : existingFormData.get(O_SUB_TABLE)));
+        Set<String> keys = new HashSet<>();
+        for (Map row : existing) {
+            keys.add(str(row, O_SUB_SUBMITTER_UID) + "|"
+                    + str(row, O_SUB_DAY_TEXT) + "|"
+                    + str(row, O_SUB_ACTIVITY));
         }
-        updateOtherApprovalInstance(finstId, otherRows, monthText, submitterUid, existingFormData);
-        return "updated";
+        int existingCount = keys.size();
+        for (OtherRow row : otherRows) {
+            keys.add(submitterUid + "|" + row.dayText + "|" + row.activity);
+        }
+        return canAppendToApprovalShard(existingCount, keys.size());
     }
 
     private void createOtherApprovalInstance(List<OtherRow> otherRows, String deptManagerId,
                                              String monthText, String submitterUid) {
+        McException.assertAccessException(
+                !hasSubtableCapacity(otherRows.size()),
+                "单个员工的其他工时审批明细超过宜搭子表 500 行限制");
         double total = 0;
         List<Map<String, Object>> sub = new ArrayList<>();
         for (OtherRow or : otherRows) {
@@ -974,6 +1038,33 @@ public class ReSubmitApprovalService {
         double addedHours = 0;
     }
 
+    static boolean hasSubtableCapacity(int rowCount) {
+        return rowCount <= MAX_APPROVAL_SUBTABLE_ROWS;
+    }
+
+    static boolean canAppendToApprovalShard(int existingCount, int mergedCount) {
+        if (!hasSubtableCapacity(mergedCount)) {
+            return false;
+        }
+        // 已有等效明细允许幂等命中;空分片允许承载一个员工的完整明细组。
+        return mergedCount == existingCount
+                || existingCount == 0
+                || mergedCount <= TARGET_APPROVAL_SUBTABLE_ROWS;
+    }
+
+    private int countUniqueRows(List<Map> rows,
+                                String uidField,
+                                String dayField,
+                                String keyField) {
+        Set<String> keys = new HashSet<>();
+        for (Map row : rows) {
+            keys.add(str(row, uidField) + "|"
+                    + str(row, dayField) + "|"
+                    + str(row, keyField));
+        }
+        return keys.size();
+    }
+
     /**
      * mergeApprovalSubRows 等价:唯一键 uid + dayText + projectCode/activity,已存在跳过
      * 保留原行时用 rebuildRow 修正 EmployeeField 主键(_id 后缀 → 主键)

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

@@ -122,4 +122,14 @@ class ReSubmitApprovalServiceTest {
         assertEquals(1, captor.getAllValues().get(0).getCurrentPage());
         assertEquals(2, captor.getAllValues().get(1).getCurrentPage());
     }
+
+    @Test
+    void shouldCreateApprovalShardWhenSubtableWouldExceedLimit() {
+        assertTrue(ReSubmitApprovalService.hasSubtableCapacity(500));
+        assertFalse(ReSubmitApprovalService.hasSubtableCapacity(501));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(80, 100));
+        assertFalse(ReSubmitApprovalService.canAppendToApprovalShard(90, 110));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(0, 120));
+        assertTrue(ReSubmitApprovalService.canAppendToApprovalShard(120, 120));
+    }
 }

+ 5 - 5
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java

@@ -36,7 +36,7 @@ public class DDImplClient implements DDClient {
         param.put("appkey", ddConf.getAppKey());
         param.put("appsecret", ddConf.getAppSecret());
         DDR r = (DDR) UtilHttp.doGet("https://oapi.dingtalk.com/gettoken", param, DDR.class);
-        log.info("响应token, {}", r.getAccessToken());
+        log.info("钉钉访问凭证获取成功");
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
         UtilToken.put("invalid-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
@@ -47,7 +47,7 @@ public class DDImplClient implements DDClient {
     public String getAccessToken(String appKey, String appSecret) {
         Map param = UtilMap.map("appkey, appsecret", appKey, appSecret);
         DDR r = DDR.doGet("https://oapi.dingtalk.com/gettoken", null, param);
-        log.info("响应token, {}", r.getAccessToken());
+        log.info("钉钉访问凭证获取成功");
         return r.getAccessToken();
     }
 
@@ -78,7 +78,7 @@ public class DDImplClient implements DDClient {
         String ticket = UtilToken.get("invalid-ticket-dingtalk");
         if (StringUtils.isNotBlank(ticket)) return ticket;
         DDR r = DDR.doGet("https://oapi.dingtalk.com/get_jsapi_ticket", null, UtilMap.map("access_token", accessToken));
-        log.info("响应ticket, {}", r.getAccessToken());
+        log.info("钉钉 JSAPI 凭证获取成功");
         ticket = r.getTicket();
         // token失效自动重置: DD重新调用会重置过期时间
         UtilToken.put("invalid-ticket-dingtalk", ticket, r.getExpiresIn() * 1000L);
@@ -104,10 +104,10 @@ public class DDImplClient implements DDClient {
         param.put("appkey", "dingmpxci8bolc3jpima");
         param.put("appsecret", "Y_k3jpKNHbGvb9S9As2Y61ZaUFNglm7SCqquIkcowLBRoc4ZpH7DG0ZTn8LyHMwI");
         DDR r = (DDR) UtilHttp.doGet("https://oapi.dingtalk.com/gettoken", param, DDR.class);
-        log.info("响应token, {}", r.getAccessToken());
+        log.info("钉钉访问凭证获取成功");
         accessToken = r.getAccessToken();
         // token失效自动重置: DD重新调用会重置过期时间
         UtilToken.put("invalid-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
         return accessToken;
     }
-}
+}

+ 16 - 5
mjava/src/main/java/com/malk/utils/UtilHttp.java

@@ -37,6 +37,8 @@ import java.util.Map;
 @Slf4j
 public abstract class UtilHttp {
 
+    private static final int DEFAULT_TIMEOUT_MILLIS = 120_000;
+
     public enum METHOD {
         POST,
         GET,
@@ -79,11 +81,20 @@ public abstract class UtilHttp {
         if (StringUtils.isNotBlank(usr) && StringUtils.isNotBlank(pwd)) {
             request.basicAuth(usr, pwd);
         }
-        HttpResponse out = request.execute();
-        log.debug("请求响应, {}, {}", out.getStatus(), out.body()); // http 状态判定
-        // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
-        //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
-        return out.body();
+        HttpResponse out = null;
+        try {
+            // fixme: 外部接口必须设置超时,避免批处理线程永久阻塞
+            out = request.timeout(DEFAULT_TIMEOUT_MILLIS).execute();
+            String responseBody = out.body();
+            log.debug("请求响应, {}, {}", out.getStatus(), responseBody); // http 状态判定
+            // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
+            //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
+            return responseBody;
+        } finally {
+            if (out != null) {
+                out.close();
+            }
+        }
     }
 
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Map body, Map form) {