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

fix(akds): 加固工时同步与异常追踪

malk пре 2 недеља
родитељ
комит
1e3949685b
24 измењених фајлова са 886 додато и 101 уклоњено
  1. 88 0
      mjava-akdsbeisen/src/main/java/com/malk/controller/AdminEndpointGuardConfig.java
  2. 1 1
      mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java
  3. 0 21
      mjava-akdsbeisen/src/main/java/com/malk/controller/PersonnelSyncController.java
  4. 38 33
      mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java
  5. 40 0
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/BackendExceptionLog.java
  6. 4 1
      mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java
  7. 12 5
      mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java
  8. 17 4
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java
  9. 192 0
      mjava-akdsbeisen/src/main/java/com/malk/service/workhours/BackendExceptionLogService.java
  10. 135 12
      mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java
  11. 2 0
      mjava-akdsbeisen/src/main/java/com/malk/timer/BeisenSyncTimer.java
  12. 40 8
      mjava-akdsbeisen/src/main/java/com/malk/timer/PersonnelSyncTimer.java
  13. 3 3
      mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java
  14. 8 2
      mjava-akdsbeisen/src/main/resources/application-dev.yml
  15. 7 1
      mjava-akdsbeisen/src/main/resources/application-prod.yml
  16. 56 0
      mjava-akdsbeisen/src/test/java/com/malk/controller/AdminEndpointGuardConfigTest.java
  17. 41 0
      mjava-akdsbeisen/src/test/java/com/malk/controller/WorkHoursControllerTest.java
  18. 23 0
      mjava-akdsbeisen/src/test/java/com/malk/service/personnel/impl/PersonnelSyncServiceImplTest.java
  19. 8 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java
  20. 112 0
      mjava-akdsbeisen/src/test/java/com/malk/service/workhours/BackendExceptionLogServiceTest.java
  21. 2 2
      mjava-akdsbeisen/src/test/java/com/malk/timer/ReSubmitApprovalTimerTest.java
  22. 54 1
      mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java
  23. 3 0
      mjava/pom.xml
  24. 0 7
      mjava/src/test/java/com/malk/McTest.java

+ 88 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/AdminEndpointGuardConfig.java

@@ -0,0 +1,88 @@
+package com.malk.controller;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 生产管理接口保护:默认关闭;临时开启时必须同时配置并携带管理令牌。
+ */
+@Slf4j
+@Configuration
+public class AdminEndpointGuardConfig implements WebMvcConfigurer {
+
+    static final String TOKEN_HEADER = "X-AKDS-Admin-Token";
+
+    static final List<String> PROTECTED_PATHS = Collections.unmodifiableList(Arrays.asList(
+            "/workhours/**",
+            "/personnel-sync/**",
+            "/approval/writeback/sync",
+            "/approval/resubmit",
+            "/approval/start",
+            "/timecard/summary/cleanup-duplicates"
+    ));
+
+    @Value("${admin-api.enabled:false}")
+    private boolean enabled;
+
+    @Value("${admin-api.token:}")
+    private String token;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(new AdminEndpointInterceptor(enabled, token))
+                .addPathPatterns(PROTECTED_PATHS);
+    }
+
+    static final class AdminEndpointInterceptor implements HandlerInterceptor {
+
+        private final boolean enabled;
+        private final String expectedToken;
+
+        AdminEndpointInterceptor(boolean enabled, String expectedToken) {
+            this.enabled = enabled;
+            this.expectedToken = expectedToken;
+        }
+
+        @Override
+        public boolean preHandle(
+                HttpServletRequest request,
+                HttpServletResponse response,
+                Object handler) throws Exception {
+            response.setHeader("Cache-Control", "no-store");
+            if (!enabled) {
+                response.sendError(HttpServletResponse.SC_NOT_FOUND);
+                return false;
+            }
+            String actualToken = request.getHeader(TOKEN_HEADER);
+            if (!tokenMatches(expectedToken, actualToken)) {
+                log.warn("[AdminAPI] 管理接口鉴权失败 method={} path={} remote={}",
+                        request.getMethod(), request.getRequestURI(), request.getRemoteAddr());
+                response.sendError(HttpServletResponse.SC_FORBIDDEN);
+                return false;
+            }
+            return true;
+        }
+    }
+
+    static boolean tokenMatches(String expectedToken, String actualToken) {
+        if (StringUtils.isBlank(expectedToken) || StringUtils.isBlank(actualToken)) {
+            return false;
+        }
+        return MessageDigest.isEqual(
+                expectedToken.getBytes(StandardCharsets.UTF_8),
+                actualToken.getBytes(StandardCharsets.UTF_8));
+    }
+}

+ 1 - 1
mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java

@@ -71,7 +71,7 @@ public class ApprovalController {
     @PostMapping("/resubmit")
     public McR<ReSubmitApprovalResult> resubmit(@RequestParam String monthText,
                                                 @RequestParam(required = false) String submitterUserId,
-                                                @RequestParam(defaultValue = "false") boolean dryRun) {
+                                                @RequestParam(defaultValue = "true") boolean dryRun) {
         log.info("resubmit, monthText={}, submitterUserId={}, dryRun={}",
                 monthText, submitterUserId, dryRun);
         return McR.success(reSubmitApprovalService.resubmit(monthText, submitterUserId, dryRun));

+ 0 - 21
mjava-akdsbeisen/src/main/java/com/malk/controller/PersonnelSyncController.java

@@ -42,27 +42,6 @@ public class PersonnelSyncController {
         return result;
     }
 
-    /**
-     * 外部触发全量同步 POST /personnel-sync/trigger
-     * 专供外部请求(宜搭连接器 / 外部定时)配置调用: 不带任何参数, 等价于一次生产全量同步
-     * (limit 取配置 limitFirstN, 生产=0 即真·全量)。与排障用的 /full 区分, 路径语义稳定、无参,
-     * 便于固定为一条外部触发请求。
-     */
-    @PostMapping("/trigger")
-    public Map<String, Object> trigger() {
-        Map<String, Object> result = new LinkedHashMap<>();
-        try {
-            Map<String, Object> stats = personnelSyncService.fullSync(null);
-            result.put("success", true);
-            result.put("stats", stats);
-        } catch (Exception e) {
-            log.error("[PersonnelSync] 外部触发全量同步失败", e);
-            result.put("success", false);
-            result.put("message", e.getMessage());
-        }
-        return result;
-    }
-
     /**
      * 干跑 GET /personnel-sync/dry-run[?limit=N]
      */

+ 38 - 33
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -3,7 +3,7 @@ package com.malk.controller;
 import com.malk.service.workhours.WorkHoursCalcService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
@@ -25,11 +25,16 @@ public class WorkHoursController {
 
     /**
      * 批量删除全部应填报工时数据
-     * GET /workhours/delete-all
+     * POST /workhours/delete-all?confirm=DELETE_ALL
      */
-    @GetMapping("/delete-all")
-    public Map<String, Object> deleteAll() {
+    @PostMapping("/delete-all")
+    public Map<String, Object> deleteAll(@RequestParam String confirm) {
         Map<String, Object> result = new LinkedHashMap<>();
+        if (!"DELETE_ALL".equals(confirm)) {
+            result.put("success", false);
+            result.put("message", "必须显式传入 confirm=DELETE_ALL");
+            return result;
+        }
         try {
             long start = System.currentTimeMillis();
             workHoursCalcService.deleteAllRequiredHours();
@@ -47,9 +52,9 @@ public class WorkHoursController {
 
     /**
      * 单员工单日验证写入(清空后做一条验证,确认归属公司等字段正确带入)
-     * GET /workhours/sync-one?userId=xxx&date=2026-04-22
+     * POST /workhours/sync-one?userId=xxx&date=2026-04-22
      */
-    @GetMapping("/sync-one")
+    @PostMapping("/sync-one")
     public Map<String, Object> syncOne(@RequestParam String userId,
                                        @RequestParam String date) {
         Map<String, Object> result = new LinkedHashMap<>();
@@ -73,9 +78,9 @@ public class WorkHoursController {
 
     /**
      * 小批量同步:前 N 个内部员工 × 当月工作日(默认 count=5)
-     * GET /workhours/sync-batch?count=5&month=2026-04
+     * POST /workhours/sync-batch?count=5&month=2026-04
      */
-    @GetMapping("/sync-batch")
+    @PostMapping("/sync-batch")
     public Map<String, Object> syncBatch(@RequestParam(defaultValue = "5") int count,
                                          @RequestParam(required = false) String month) {
         Map<String, Object> result = new LinkedHashMap<>();
@@ -101,11 +106,11 @@ public class WorkHoursController {
 
     /**
      * 回填存量应填报工时的「是否cf员工」字段(textField_mpp7a2k7),值取自人员档案
-     * GET /workhours/backfill-cf            (实际更新)
-     * GET /workhours/backfill-cf?dryRun=true(仅扫描预览,不更新)
+     * POST /workhours/backfill-cf             (仅扫描预览,不更新)
+     * POST /workhours/backfill-cf?dryRun=false(实际更新)
      */
-    @GetMapping("/backfill-cf")
-    public Map<String, Object> backfillCf(@RequestParam(defaultValue = "false") boolean dryRun) {
+    @PostMapping("/backfill-cf")
+    public Map<String, Object> backfillCf(@RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
             long start = System.currentTimeMillis();
@@ -126,11 +131,11 @@ public class WorkHoursController {
     /**
      * 回填存量应填报工时的「归属公司」+「属性」两字段, 值取自人员档案
      * ppExt: 一次性接口, 不设定时; 主流程 upsertDailyHours 已带最新两字段, 后续变动自动同步, 老记录靠本接口一次对齐
-     * GET /workhours/backfill-company-attr            (实际更新)
-     * GET /workhours/backfill-company-attr?dryRun=true(仅扫描预览, 不更新)
+     * POST /workhours/backfill-company-attr             (仅扫描预览, 不更新)
+     * POST /workhours/backfill-company-attr?dryRun=false(实际更新)
      */
-    @GetMapping("/backfill-company-attr")
-    public Map<String, Object> backfillCompanyAttr(@RequestParam(defaultValue = "false") boolean dryRun) {
+    @PostMapping("/backfill-company-attr")
+    public Map<String, Object> backfillCompanyAttr(@RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
             long start = System.currentTimeMillis();
@@ -150,10 +155,10 @@ public class WorkHoursController {
 
     /**
      * 清理同一员工同一日期的重复应填报工时(一次性接口,默认仅预览)
-     * GET /workhours/cleanup-duplicates             (仅预览)
-     * GET /workhours/cleanup-duplicates?dryRun=false(实际删除)
+     * POST /workhours/cleanup-duplicates             (仅预览)
+     * POST /workhours/cleanup-duplicates?dryRun=false(实际删除)
      */
-    @GetMapping("/cleanup-duplicates")
+    @PostMapping("/cleanup-duplicates")
     public Map<String, Object> cleanupDuplicates(@RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
@@ -174,10 +179,10 @@ public class WorkHoursController {
 
     /**
      * 清理已离职员工「离职日之后」的历史应报工时(一次性接口,默认仅预览)
-     * GET /workhours/cleanup-after-offline             (仅统计不删除)
-     * GET /workhours/cleanup-after-offline?dryRun=false(实际删除)
+     * POST /workhours/cleanup-after-offline             (仅统计不删除)
+     * POST /workhours/cleanup-after-offline?dryRun=false(实际删除)
      */
-    @GetMapping("/cleanup-after-offline")
+    @PostMapping("/cleanup-after-offline")
     public Map<String, Object> cleanupAfterOffline(@RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
@@ -198,9 +203,9 @@ public class WorkHoursController {
 
     /**
      * 清理员工入职日期之前的历史应报工时,入职当天保留(默认仅预览)。
-     * GET /workhours/cleanup-before-hired-date?dryRun=true|false
+     * POST /workhours/cleanup-before-hired-date?dryRun=true|false
      */
-    @GetMapping("/cleanup-before-hired-date")
+    @PostMapping("/cleanup-before-hired-date")
     public Map<String, Object> cleanupBeforeHiredDate(
             @RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
@@ -221,13 +226,13 @@ public class WorkHoursController {
 
     /**
      * 清理已写入的"未来"应报工时 (workDay > cutoff, cutoff 缺省 today)
-     * GET /workhours/cleanup-future                    (实际删除, cutoff=today)
-     * GET /workhours/cleanup-future?dryRun=true        (仅预览)
-     * GET /workhours/cleanup-future?cutoff=2026-07-15  (指定保留截止日)
+     * POST /workhours/cleanup-future                    (仅预览, cutoff=today)
+     * POST /workhours/cleanup-future?dryRun=false       (实际删除)
+     * POST /workhours/cleanup-future?cutoff=2026-07-15  (指定保留截止日)
      */
-    @GetMapping("/cleanup-future")
+    @PostMapping("/cleanup-future")
     public Map<String, Object> cleanupFuture(@RequestParam(required = false) String cutoff,
-                                             @RequestParam(defaultValue = "false") boolean dryRun) {
+                                             @RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
             LocalDate cutoffDate = (cutoff == null || cutoff.isEmpty()) ? null : LocalDate.parse(cutoff);
@@ -248,10 +253,10 @@ public class WorkHoursController {
 
     /**
      * 项目变更审批增量同步(手动触发;定时器 ProjectChangeSyncTimer 定期跑)
-     * GET /workhours/sync-project-changes?daysBack=7
+     * POST /workhours/sync-project-changes?daysBack=7
      * 只更新变更时间未来的应报工时 Manager, 变更前记录一律不动
      */
-    @GetMapping("/sync-project-changes")
+    @PostMapping("/sync-project-changes")
     public Map<String, Object> syncProjectChanges(@RequestParam(defaultValue = "7") int daysBack) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
@@ -272,9 +277,9 @@ public class WorkHoursController {
 
     /**
      * 全量同步当月应填报工时
-     * GET /workhours/sync?month=2026-04
+     * POST /workhours/sync?month=2026-04
      */
-    @GetMapping("/sync")
+    @PostMapping("/sync")
     public Map<String, Object> sync(@RequestParam(required = false) String month) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {

+ 40 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/BackendExceptionLog.java

@@ -0,0 +1,40 @@
+package com.malk.server.workhours;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 宜搭后端异常日志写入模型。
+ */
+@Data
+@Builder
+public class BackendExceptionLog {
+
+    private Long occurredAt;
+
+    private String category;
+
+    private String sourceSystem;
+
+    private String sourceModule;
+
+    private String sourceAction;
+
+    private String sourceDescription;
+
+    private String businessObjectType;
+
+    private String businessObjectId;
+
+    private String traceId;
+
+    private String errorCode;
+
+    private String summary;
+
+    private String detail;
+
+    private Boolean retryable;
+
+    private Integer retryCount;
+}

+ 4 - 1
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java

@@ -27,6 +27,9 @@ public class WHConf {
     // prd 审批回写:工时汇总表 + 两类审批单
     private String formUuidWorkHoursSummary;
 
+    // prd 通用后端异常日志表:业务异常只记录结构化摘要,不写入凭据和完整请求正文
+    private String formUuidBackendExceptionLog;
+
     private String formUuidApproval;
 
     private String formUuidOtherApproval;
@@ -66,7 +69,7 @@ public class WHConf {
     private String approvalOriginatorDeptId;
 
     // prd 每日补发起仅扫描最近 N 个自然日发生修改的汇总记录,再按记录实际月份 + 员工定向补发
-    private int approvalResubmitModifiedLookbackDays = 3;
+    private int approvalResubmitModifiedLookbackDays = 7;
 
     // 汇总表: 审批状态字段(SelectField, 3 档 "暂存/审批中/已审批")
     // prd 前端在保存/提交时写入此字段,后端补发成功后同步为“审批中”

+ 12 - 5
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -91,7 +91,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
 
         List<Map> dingUsers = fetchAllDingUsers();
         Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
-        enrichManagers(dingUserMap);
+        int managerLookupFailed = enrichManagers(dingUserMap);
         log.info("[PersonnelSync] 钉钉拉取 {} 人 (去重后{})", dingUserMap.size(), limited ? ", 已截断前" + limit + "条" : "");
 
         Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
@@ -116,6 +116,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         result.put("updated", writeStats.updated.get());
         result.put("markedInactive", writeStats.markedInactive.get());
         result.put("failed", writeStats.failed.get());
+        result.put("managerLookupFailed", managerLookupFailed);
         result.put("durationMs", cost);
         log.info("[PersonnelSync] 全量同步完成 {}", result);
         return result;
@@ -128,7 +129,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         boolean limited = limit > 0;
         List<Map> dingUsers = fetchAllDingUsers();
         Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
-        enrichManagers(dingUserMap);
+        int managerLookupFailed = enrichManagers(dingUserMap);
         Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
         List<Action> actions = diff(dingUserMap, yidaMap, limited);
 
@@ -141,6 +142,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         actionCounts.put("update", stats.getOrDefault(ACTION_UPDATE, 0L));
         actionCounts.put("markOff", stats.getOrDefault(ACTION_MARK_OFF, 0L));
         result.put("actions", actionCounts);
+        result.put("managerLookupFailed", managerLookupFailed);
         result.put("durationMs", System.currentTimeMillis() - start);
         // 抽样 10 条展示预期动作
         List<Map<String, Object>> sample = new ArrayList<>();
@@ -985,10 +987,11 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
 
     /** topapi/v2/user/list 不返回 manager_userid, 按需逐人补 (仅当 fieldManager 已配置; 单人失败不影响整体) */
     @SuppressWarnings("unchecked")
-    private void enrichManagers(Map<String, Map> dingUserMap) {
-        if (!notBlank(conf.getFieldManager()) || dingUserMap.isEmpty()) return;
+    int enrichManagers(Map<String, Map> dingUserMap) {
+        if (!notBlank(conf.getFieldManager()) || dingUserMap.isEmpty()) return 0;
         String token = ddClient.getAccessToken();
         int filled = 0;
+        int failed = 0;
         for (Map u : dingUserMap.values()) {
             if (u.get("manager_userid") != null) { filled++; continue; }
             Object uid = u.get("userid");
@@ -1002,10 +1005,13 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
                     filled++;
                 }
             } catch (Exception ex) {
+                failed++;
                 log.warn("[PersonnelSync] 取 manager_userid 失败 userid={} err={}", uid, ex.getMessage());
             }
         }
-        log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人", filled, dingUserMap.size());
+        log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人, 查询失败{}人",
+                filled, dingUserMap.size(), failed);
+        return failed;
     }
 
     private Map<String, Long> countActions(List<Action> actions) {
@@ -1043,4 +1049,5 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         AtomicInteger markedInactive = new AtomicInteger();
         AtomicInteger failed = new AtomicInteger();
     }
+
 }

+ 17 - 4
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ApprovalWriteBackService.java

@@ -169,8 +169,10 @@ public class ApprovalWriteBackService {
 
         // 4. 按汇总表记录键(填报人 + 工时日期文本)分组
         Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
+        int invalidRows = 0;
         for (DetailRow r : rows) {
             if (StringUtils.isBlank(r.userId) || StringUtils.isBlank(r.dayText)) {
+                invalidRows++;
                 continue;
             }
             groups.computeIfAbsent(r.userId + "" + r.dayText, k -> new ArrayList<>()).add(r);
@@ -230,10 +232,14 @@ public class ApprovalWriteBackService {
 
         // 6. 同步状态汇总(syncTotal=分组数;failCount=失败组数)
         // fixme 状态字面值对齐宜搭审批表 selectField_mq58cd5p 选项:"全部成功" / "部分失败" / "全部失败"
-        int syncTotal = groups.size();
-        int failCount = failRecords.get();
-        String syncStatus = (syncTotal == 0 || failCount == 0) ? "全部成功"
-                : (failCount < syncTotal ? "部分失败" : "全部失败");
+        int syncTotal = groups.size() + invalidRows;
+        int failCount = failRecords.get() + invalidRows;
+        // fixme 审批明细为空同样不能标记为成功,否则兜底定时会永久跳过该异常实例。
+        if (syncTotal == 0) {
+            syncTotal = 1;
+            failCount = 1;
+        }
+        String syncStatus = determineSyncStatus(syncTotal, failCount);
 
         ApprovalWriteBackResult stats = ApprovalWriteBackResult.builder()
                 .formType(isOther ? "其他工时审批" : "工时审批")
@@ -258,6 +264,13 @@ public class ApprovalWriteBackService {
         return stats;
     }
 
+    static String determineSyncStatus(int syncTotal, int failCount) {
+        if (syncTotal <= 0 || failCount >= syncTotal) {
+            return "全部失败";
+        }
+        return failCount > 0 ? "部分失败" : "全部成功";
+    }
+
     private Map queryApprovalFormData(String instanceId) {
         try {
             DDR_New processDetail = ydClient.queryData(YDParam.builder()

+ 192 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/BackendExceptionLogService.java

@@ -0,0 +1,192 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.workhours.BackendExceptionLog;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * 通用宜搭后端异常日志服务。
+ *
+ * <p>同一异常指纹执行 upsert,避免定时重试持续产生重复记录;日志写入失败不影响原业务。</p>
+ */
+@Slf4j
+@Service
+public class BackendExceptionLogService {
+
+    public static final String CATEGORY_DATA_VALIDATION = "数据校验异常";
+    public static final String CATEGORY_BUSINESS_DATA_MISSING = "业务数据缺失";
+    public static final String CATEGORY_SCHEDULED_TASK = "定时任务异常";
+    public static final String CATEGORY_PROGRAM = "程序异常";
+    public static final String SOURCE_AKDS_BACKEND = "阿科德斯后端";
+    public static final String SOURCE_YIDA = "宜搭";
+
+    private static final String LOG_ID_FIELD = "textField_g8r9qc3eb";
+    private static final String OCCURRED_AT_FIELD = "dateField_g8r9riv7q";
+    private static final String ENVIRONMENT_FIELD = "selectField_g8r9ssm3g";
+    private static final String STATUS_FIELD = "selectField_g8r9t2n63";
+    private static final String CATEGORY_FIELD = "selectField_g8r9vrxdx";
+    private static final String SOURCE_SYSTEM_FIELD = "selectField_g8r9wg5st";
+    private static final String SOURCE_MODULE_FIELD = "textField_g8r9xv5fr";
+    private static final String SOURCE_ACTION_FIELD = "textField_g8r9yi4f6";
+    private static final String SOURCE_DESCRIPTION_FIELD = "textareaField_g8r9zrbk0";
+    private static final String BUSINESS_OBJECT_TYPE_FIELD = "textField_g8r910eewm";
+    private static final String BUSINESS_OBJECT_ID_FIELD = "textField_g8r911zq1g";
+    private static final String TRACE_ID_FIELD = "textField_g8r9128s8b";
+    private static final String ERROR_CODE_FIELD = "textField_g8r914s87j";
+    private static final String SUMMARY_FIELD = "textField_g8r915esft";
+    private static final String DETAIL_FIELD = "textareaField_g8r9165818";
+    private static final String RETRYABLE_FIELD = "radioField_g8r917smpw";
+    private static final String RETRY_COUNT_FIELD = "numberField_g8r918ma1q";
+
+    private static final int SHORT_TEXT_LIMIT = 200;
+    private static final int LONG_TEXT_LIMIT = 2000;
+    private static final Pattern AUTHORIZATION_PATTERN = Pattern.compile(
+            "(?i)(authorization\\s*[:=]\\s*)(?:bearer\\s+)?[^\\s,;]+");
+    private static final Pattern SECRET_PATTERN = Pattern.compile(
+            "(?i)((?:token|secret|password|cookie)\\s*[:=]\\s*)[^\\s,;]+");
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private WHConf whConf;
+
+    @Autowired
+    private Environment environment;
+
+    /**
+     * 异步记录异常;任何日志链路异常都只写本地日志,不反向抛给业务调用方。
+     *
+     * @param event 结构化异常事件
+     */
+    @Async("aliworkConcurrence")
+    public void record(BackendExceptionLog event) {
+        if (!isValid(event) || StringUtils.isBlank(whConf.getFormUuidBackendExceptionLog())) {
+            log.warn("[BackendExceptionLog] 跳过异常日志写入,事件或表单配置不完整 sourceModule={} errorCode={}",
+                    event == null ? null : event.getSourceModule(),
+                    event == null ? null : event.getErrorCode());
+            return;
+        }
+        String logId = buildLogId(event);
+        try {
+            Map<String, Object> formData = buildFormData(event, logId);
+            Map<String, Object> searchCondition = new LinkedHashMap<>();
+            searchCondition.put(LOG_ID_FIELD, logId);
+            ydClient.operateData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(whConf.getFormUuidBackendExceptionLog())
+                    .searchCondition(JSON.toJSONString(searchCondition))
+                    .formDataJson(JSON.toJSONString(formData))
+                    .build(), YDConf.FORM_OPERATION.upsert);
+            log.info("[BackendExceptionLog] 宜搭异常日志已记录 logId={} sourceModule={} errorCode={}",
+                    logId, event.getSourceModule(), event.getErrorCode());
+        } catch (Exception ex) {
+            log.error("[BackendExceptionLog] 宜搭异常日志写入失败 logId={} sourceModule={} errorCode={}",
+                    logId, event.getSourceModule(), event.getErrorCode(), ex);
+        }
+    }
+
+    static String buildLogId(BackendExceptionLog event) {
+        String fingerprint = String.join("|",
+                value(event.getCategory()),
+                value(event.getSourceSystem()),
+                value(event.getSourceModule()),
+                value(event.getSourceAction()),
+                value(event.getBusinessObjectType()),
+                value(event.getBusinessObjectId()),
+                value(event.getErrorCode()));
+        return "LOG-" + UUID.nameUUIDFromBytes(
+                fingerprint.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private Map<String, Object> buildFormData(BackendExceptionLog event, String logId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put(LOG_ID_FIELD, logId);
+        data.put(OCCURRED_AT_FIELD,
+                event.getOccurredAt() == null ? System.currentTimeMillis() : event.getOccurredAt());
+        data.put(ENVIRONMENT_FIELD, resolveEnvironment());
+        data.put(STATUS_FIELD, "待处理");
+        data.put(CATEGORY_FIELD, event.getCategory());
+        data.put(SOURCE_SYSTEM_FIELD, event.getSourceSystem());
+        data.put(SOURCE_MODULE_FIELD, sanitize(event.getSourceModule(), SHORT_TEXT_LIMIT));
+        putIfNotBlank(data, SOURCE_ACTION_FIELD, event.getSourceAction(), SHORT_TEXT_LIMIT);
+        data.put(SOURCE_DESCRIPTION_FIELD,
+                sanitize(event.getSourceDescription(), LONG_TEXT_LIMIT));
+        putIfNotBlank(data, BUSINESS_OBJECT_TYPE_FIELD,
+                event.getBusinessObjectType(), SHORT_TEXT_LIMIT);
+        putIfNotBlank(data, BUSINESS_OBJECT_ID_FIELD,
+                event.getBusinessObjectId(), SHORT_TEXT_LIMIT);
+        putIfNotBlank(data, TRACE_ID_FIELD, event.getTraceId(), SHORT_TEXT_LIMIT);
+        putIfNotBlank(data, ERROR_CODE_FIELD, event.getErrorCode(), SHORT_TEXT_LIMIT);
+        data.put(SUMMARY_FIELD, sanitize(event.getSummary(), SHORT_TEXT_LIMIT));
+        putIfNotBlank(data, DETAIL_FIELD, event.getDetail(), LONG_TEXT_LIMIT);
+        data.put(RETRYABLE_FIELD, event.getRetryable() == null
+                ? "未知" : (event.getRetryable() ? "是" : "否"));
+        if (event.getRetryCount() != null) {
+            data.put(RETRY_COUNT_FIELD, Math.max(0, event.getRetryCount()));
+        }
+        return data;
+    }
+
+    private String resolveEnvironment() {
+        if (Arrays.asList(environment.getActiveProfiles()).contains("prod")) {
+            return "生产";
+        }
+        if (Arrays.asList(environment.getActiveProfiles()).contains("dev")) {
+            return "开发";
+        }
+        return "测试";
+    }
+
+    private static boolean isValid(BackendExceptionLog event) {
+        return event != null
+                && StringUtils.isNoneBlank(
+                event.getCategory(),
+                event.getSourceSystem(),
+                event.getSourceModule(),
+                event.getSourceDescription(),
+                event.getSummary());
+    }
+
+    private static void putIfNotBlank(
+            Map<String, Object> data,
+            String fieldId,
+            String value,
+            int maxLength) {
+        if (StringUtils.isNotBlank(value)) {
+            data.put(fieldId, sanitize(value, maxLength));
+        }
+    }
+
+    static String sanitize(String value, int maxLength) {
+        if (StringUtils.isBlank(value)) {
+            return "";
+        }
+        String sanitized = AUTHORIZATION_PATTERN.matcher(value)
+                .replaceAll("$1***");
+        sanitized = SECRET_PATTERN.matcher(sanitized)
+                .replaceAll("$1***");
+        return StringUtils.abbreviate(sanitized, maxLength);
+    }
+
+    private static String value(String value) {
+        return StringUtils.defaultString(value);
+    }
+}

+ 135 - 12
mjava-akdsbeisen/src/main/java/com/malk/timer/ApprovalWriteBackTimer.java

@@ -4,9 +4,12 @@ import com.alibaba.fastjson.JSON;
 import com.malk.server.aliwork.YDConf;
 import com.malk.server.aliwork.YDParam;
 import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.ApprovalWriteBackResult;
+import com.malk.server.workhours.BackendExceptionLog;
 import com.malk.server.workhours.WHConf;
 import com.malk.service.aliwork.YDClient;
 import com.malk.service.workhours.ApprovalWriteBackService;
+import com.malk.service.workhours.BackendExceptionLogService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -23,11 +26,11 @@ import java.util.concurrent.atomic.AtomicInteger;
  * 审批回写兜底定时(bug 2 修复)
  * <p>
  * 每天 04:20、12:20 触发,扫描两类审批单 instanceStatus=COMPLETED、
- * 同步状态 selectField_mq58cd5p 为空的实例,主动调 ApprovalWriteBackService.writeBack 补齐工时汇总表回写。
+ * 同步状态非“全部成功”的实例,主动调 ApprovalWriteBackService.writeBack 补齐工时汇总表回写。
  * <p>
  * 触发原因场景:
  * 1. 审批完成时宜搭端未回调,writeBack 从未被调
- * 2. writeBack 调用但网络/宜搭异常,回写失败(会被 syncStatus 置为"部分失败"或"全部失败",此定时兜底不会重复处理)
+ * 2. writeBack 调用但网络/宜搭异常,回写失败(syncStatus 为“部分失败”或“全部失败”时继续补偿)
  * <p>
  * 幂等:writeBack 内部 REJECT→0 / APPROVE←hours, 重复执行结果一致
  * <p>
@@ -50,11 +53,14 @@ public class ApprovalWriteBackTimer {
     @Autowired
     private ApprovalWriteBackService approvalWriteBackService;
 
+    @Autowired
+    private BackendExceptionLogService backendExceptionLogService;
+
     private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
 
     /**
      * 每天 04:20、12:20 兜底扫描审批单。
-     * prd 中午回写先于 12:45 审批补发执行,先消化已完成审批,再检查剩余漏发记录。
+     * prd 中午回写先于 12:55 审批补发执行,先消化已完成审批,再检查剩余漏发记录。
      */
     @Scheduled(cron = "0 20 4 * * ?")
     @Scheduled(cron = "0 20 12 * * ?")
@@ -64,7 +70,7 @@ public class ApprovalWriteBackTimer {
             return;
         }
         try {
-            log.info("[审批回写兜底] 开始扫描 syncStatus 的 COMPLETED 审批单");
+            log.info("[审批回写兜底] 开始扫描 syncStatus 非全部成功的 COMPLETED 审批单");
             AtomicInteger totalScanned = new AtomicInteger(0);
             AtomicInteger totalTriggered = new AtomicInteger(0);
             AtomicInteger totalFailed = new AtomicInteger(0);
@@ -79,6 +85,7 @@ public class ApprovalWriteBackTimer {
                     totalScanned.get(), totalTriggered.get(), totalFailed.get());
         } catch (Exception e) {
             log.error("[审批回写兜底] 定时任务异常", e);
+            recordTimerException(e);
         } finally {
             RUNNING.set(false);
         }
@@ -104,29 +111,44 @@ public class ApprovalWriteBackTimer {
             }
             for (Map item : data) {
                 scanned.incrementAndGet();
+                Object processInstanceId = item.get("processInstanceId");
+                String pid = processInstanceId == null ? "" : String.valueOf(processInstanceId);
+                Object formInstanceId = item.get("formInstanceId");
+                String finstId = formInstanceId == null ? pid : String.valueOf(formInstanceId);
                 // fixme getInstances 当前返回字段名为 data;兼容旧响应中的 formData。
                 Map fd = extractFormData(item);
                 if (fd == null) {
+                    failed.incrementAndGet();
                     log.warn("[审批回写兜底] 流程实例缺少表单数据 keys={}", item.keySet());
+                    recordMissingFormData(tag, finstId, item);
                     continue;
                 }
-                String syncStatus = String.valueOf(fd.getOrDefault(whConf.getApprovalSyncStatusField(), ""));
-                if (StringUtils.isNotBlank(syncStatus) && !"null".equals(syncStatus)) {
+                String syncStatusField = resolveSyncStatusField(tag);
+                String syncStatus = String.valueOf(fd.getOrDefault(syncStatusField, ""));
+                if (!shouldRetrySyncStatus(syncStatus)) {
+                    continue;
+                }
+                Integer resultCode = resolveResultCode(item.get("approvedResult"));
+                if (resultCode == null) {
+                    failed.incrementAndGet();
+                    log.warn("[审批回写兜底] 审批结果缺失或未知,跳过并等待下轮 tag={} formInstanceId={} approvedResult={}",
+                            tag, finstId, item.get("approvedResult"));
                     continue;
                 }
-                String pid = String.valueOf(item.get("processInstanceId"));
-                Object fInstObj = item.get("formInstanceId");
-                String finstId = fInstObj != null ? String.valueOf(fInstObj) : pid;
-                String approvedResult = String.valueOf(item.getOrDefault("approvedResult", "agree"));
-                int resultCode = "agree".equals(approvedResult) ? 1 : 0;
                 try {
-                    approvalWriteBackService.writeBack(finstId, resultCode);
+                    ApprovalWriteBackResult result =
+                            approvalWriteBackService.writeBack(finstId, resultCode);
                     triggered.incrementAndGet();
+                    if (result == null || !"全部成功".equals(result.getSyncStatus())) {
+                        failed.incrementAndGet();
+                        recordWriteBackFailure(tag, finstId, result);
+                    }
                     log.info("[审批回写兜底] 触发 writeBack tag={} formInstanceId={} result={}",
                             tag, finstId, resultCode);
                 } catch (Exception ex) {
                     failed.incrementAndGet();
                     log.error("[审批回写兜底] writeBack 失败 tag={} formInstanceId={}", tag, finstId, ex);
+                    recordWriteBackException(tag, finstId, ex);
                 }
             }
             if (!hasNextPage(page, PAGE_SIZE, r.getTotalCount(), data.size())) {
@@ -144,6 +166,107 @@ public class ApprovalWriteBackTimer {
         return totalCount <= 0 || (long) pageNumber * pageSize < totalCount;
     }
 
+    static boolean shouldRetrySyncStatus(String syncStatus) {
+        return !"全部成功".equals(StringUtils.trimToEmpty(syncStatus));
+    }
+
+    String resolveSyncStatusField(String tag) {
+        return "other".equals(tag)
+                ? whConf.getOtherApprovalSyncStatusField()
+                : whConf.getApprovalSyncStatusField();
+    }
+
+    private void recordMissingFormData(String tag, String formInstanceId, Map item) {
+        backendExceptionLogService.record(BackendExceptionLog.builder()
+                .category(BackendExceptionLogService.CATEGORY_DATA_VALIDATION)
+                .sourceSystem(BackendExceptionLogService.SOURCE_YIDA)
+                .sourceModule("ApprovalWriteBackTimer")
+                .sourceAction("scanForm")
+                .sourceDescription("审批回写兜底扫描读取 COMPLETED 流程实例表单数据")
+                .businessObjectType(tag + "审批单")
+                .businessObjectId(formInstanceId)
+                .errorCode("APPROVAL_FORM_DATA_MISSING")
+                .summary("审批流程实例缺少表单数据")
+                .detail("processKeys=" + item.keySet())
+                .retryable(true)
+                .retryCount(1)
+                .build());
+    }
+
+    private void recordWriteBackFailure(
+            String tag,
+            String formInstanceId,
+            ApprovalWriteBackResult result) {
+        String detail = result == null
+                ? "writeBackResult=null"
+                : "syncStatus=" + result.getSyncStatus()
+                + ", syncTotal=" + result.getSyncTotal()
+                + ", failCount=" + result.getFailCount()
+                + ", missRows=" + result.getMissRows();
+        backendExceptionLogService.record(BackendExceptionLog.builder()
+                .category(BackendExceptionLogService.CATEGORY_BUSINESS_DATA_MISSING)
+                .sourceSystem(BackendExceptionLogService.SOURCE_AKDS_BACKEND)
+                .sourceModule("ApprovalWriteBackTimer")
+                .sourceAction("writeBack")
+                .sourceDescription("审批完成后回写工时汇总表,结果不是全部成功")
+                .businessObjectType(tag + "审批单")
+                .businessObjectId(formInstanceId)
+                .errorCode("APPROVAL_WRITEBACK_NOT_ALL_SUCCESS")
+                .summary("审批回写未全部成功")
+                .detail(detail)
+                .retryable(true)
+                .retryCount(1)
+                .build());
+    }
+
+    private void recordWriteBackException(
+            String tag,
+            String formInstanceId,
+            Exception exception) {
+        backendExceptionLogService.record(BackendExceptionLog.builder()
+                .category(BackendExceptionLogService.CATEGORY_PROGRAM)
+                .sourceSystem(BackendExceptionLogService.SOURCE_AKDS_BACKEND)
+                .sourceModule("ApprovalWriteBackTimer")
+                .sourceAction("writeBack")
+                .sourceDescription("审批回写兜底任务调用审批回写服务")
+                .businessObjectType(tag + "审批单")
+                .businessObjectId(formInstanceId)
+                .errorCode("APPROVAL_WRITEBACK_EXCEPTION")
+                .summary("审批回写服务执行异常")
+                .detail(exception.getClass().getName() + ": " + exception.getMessage())
+                .retryable(true)
+                .retryCount(1)
+                .build());
+    }
+
+    private void recordTimerException(Exception exception) {
+        backendExceptionLogService.record(BackendExceptionLog.builder()
+                .category(BackendExceptionLogService.CATEGORY_SCHEDULED_TASK)
+                .sourceSystem(BackendExceptionLogService.SOURCE_AKDS_BACKEND)
+                .sourceModule("ApprovalWriteBackTimer")
+                .sourceAction("scanAndWriteBack")
+                .sourceDescription("每天 04:20、12:20 扫描两类 COMPLETED 审批并补偿回写")
+                .errorCode("APPROVAL_WRITEBACK_TIMER_EXCEPTION")
+                .summary("审批回写兜底定时任务异常")
+                .detail(exception.getClass().getName() + ": " + exception.getMessage())
+                .retryable(true)
+                .retryCount(1)
+                .build());
+    }
+
+    static Integer resolveResultCode(Object approvedResult) {
+        String value = approvedResult == null
+                ? "" : StringUtils.trimToEmpty(String.valueOf(approvedResult)).toLowerCase(Locale.ROOT);
+        if ("agree".equals(value)) {
+            return 1;
+        }
+        if ("refuse".equals(value) || "reject".equals(value)
+                || "terminate".equals(value) || "cancel".equals(value)) {
+            return 0;
+        }
+        return null;
+    }
+
     @SuppressWarnings("unchecked")
     static Map extractFormData(Map item) {
         if (item == null) {

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/BeisenSyncTimer.java

@@ -3,6 +3,7 @@ package com.malk.timer;
 import com.malk.service.beisen.BSDataSyncService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -10,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled;
 @Slf4j
 @Configuration
 @EnableScheduling
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
 public class BeisenSyncTimer {
 
     @Autowired

+ 40 - 8
mjava-akdsbeisen/src/main/java/com/malk/timer/PersonnelSyncTimer.java

@@ -3,20 +3,23 @@ package com.malk.timer;
 import com.malk.service.personnel.PersonnelSyncService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
 
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.Map;
 
 /**
  * 钉钉 -> 宜搭 人员档案 定时全量增量同步
- * 工作日 (MON-FRI) 每天 08:00 / 13:00 / 18:00 各一次 fullSync (limit 取配置 personnel-sync.limitFirstN, 生产为 0 即真·全量)
+ * 工作日 (MON-FRI) 每天 06:00 / 13:00 / 19:00 各一次 fullSync (limit 取配置 personnel-sync.limitFirstN, 生产为 0 即真·全量)
  * 共 3 次/工作日; 周末不跑
  */
 @Slf4j
 @Configuration
 @EnableScheduling
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true", matchIfMissing = false)
 public class PersonnelSyncTimer {
 
     @Autowired
@@ -47,18 +50,23 @@ public class PersonnelSyncTimer {
             for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
                 try {
                     java.util.Map<String, Object> stats = personnelSyncService.fullSync(null);
+                    if (hasSyncFailures(stats)) {
+                        log.warn("[PersonnelSync] 定时同步存在未完成记录 tag={} attempt={}/{} {}",
+                                tag, attempt, MAX_ATTEMPTS, stats);
+                        if (attempt < MAX_ATTEMPTS) {
+                            if (!waitBeforeRetry(tag)) return;
+                            continue;
+                        }
+                        log.error("[PersonnelSync] 定时同步连续 {} 次仍有未完成记录,等待下一次 cron tag={} {}",
+                                MAX_ATTEMPTS, tag, stats);
+                        return;
+                    }
                     log.info("[PersonnelSync] 定时同步任务完成 tag={} attempt={}/{} {}", tag, attempt, MAX_ATTEMPTS, stats);
                     return;
                 } catch (Exception e) {
                     log.error("[PersonnelSync] 定时同步任务失败 tag={} attempt={}/{}", tag, attempt, MAX_ATTEMPTS, e);
                     if (attempt < MAX_ATTEMPTS) {
-                        try {
-                            Thread.sleep(RETRY_DELAY_MS);
-                        } catch (InterruptedException ie) {
-                            Thread.currentThread().interrupt();
-                            log.warn("[PersonnelSync] 重试等待被中断,放弃本轮 tag={}", tag);
-                            return;
-                        }
+                        if (!waitBeforeRetry(tag)) return;
                     }
                 }
             }
@@ -67,4 +75,28 @@ public class PersonnelSyncTimer {
             running.set(false);
         }
     }
+
+    static boolean hasSyncFailures(Map<String, Object> stats) {
+        return stats == null
+                || count(stats, "failed") > 0
+                || count(stats, "managerLookupFailed") > 0;
+    }
+
+    private static int count(Map<String, Object> stats, String key) {
+        if (stats == null || !(stats.get(key) instanceof Number)) {
+            return 0;
+        }
+        return ((Number) stats.get(key)).intValue();
+    }
+
+    private static boolean waitBeforeRetry(String tag) {
+        try {
+            Thread.sleep(RETRY_DELAY_MS);
+            return true;
+        } catch (InterruptedException ie) {
+            Thread.currentThread().interrupt();
+            log.warn("[PersonnelSync] 重试等待被中断,放弃本轮 tag={}", tag);
+            return false;
+        }
+    }
 }

+ 3 - 3
mjava-akdsbeisen/src/main/java/com/malk/timer/ReSubmitApprovalTimer.java

@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 /**
  * 补发起工时审批定时(bug 1 修复)
  * <p>
- * 每天 05:20、12:45 触发;先让 04:20、12:20 回写兜底消化已完成审批,再扫描最近 3 个自然日发生修改的汇总记录,
+ * 每天 05:20、12:55 触发;先让 04:20、12:20 回写兜底消化已完成审批,再扫描最近 7 个自然日发生修改的汇总记录,
  * 按实际业务 (month, userId) 去重,
  * 走 ReSubmitApprovalService.resubmit 逻辑:queryProcess(RUNNING) 有则 update / 无则 create。
  * <p>
@@ -99,10 +99,10 @@ public class ReSubmitApprovalTimer {
     }
 
     /**
-     * prd 每天中午 12:45 再执行一次补发兜底,缩短上午提交到日终封账之间的漏发窗口
+     * prd 每天中午 12:55 再执行一次补发兜底,与 12:45 应报工时增量任务错开
      * 封账月份仍由 {@link #scanAndReSubmit()} 内的实时封账校验统一跳过。
      */
-    @Scheduled(cron = "0 45 12 * * ?")
+    @Scheduled(cron = "0 55 12 * * ?")
     public void scanAndReSubmitAtNoon() {
         scanAndReSubmit();
     }

+ 8 - 2
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -6,7 +6,12 @@ server:
 
 
 enable:
-   scheduling: false
+  scheduling: false
+
+# dev 同样默认关闭管理/修复接口,需显式开启并通过环境变量配置令牌
+admin-api:
+  enabled: false
+  token: "${AKDS_ADMIN_TOKEN:}"
 
 # condition
 spel:
@@ -78,6 +83,7 @@ workhours:
   formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
+  formUuidBackendExceptionLog: "FORM-20F89FE062FC4726B540975E9E269075NFWS"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
   formUuidOtherApproval: "FORM-4828E0E40CD34038825E8C6E25417B2718NF"   # 正式 FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
@@ -98,7 +104,7 @@ workhours:
   otherApprovalProcessCode: "TPROC--AO666S81..."     # 测试环境, 上线切正式
   approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
-  approvalResubmitModifiedLookbackDays: 3
+  approvalResubmitModifiedLookbackDays: 7
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # prd 月度封账流程:TimeCard 实时锁定 + 封账提交前审批完整性校验

+ 7 - 1
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -7,6 +7,11 @@ server:
 enable:
   scheduling: true
 
+# prd 管理/修复接口默认隐藏;临时开启时还必须通过环境变量配置管理令牌
+admin-api:
+  enabled: false
+  token: "${AKDS_ADMIN_TOKEN:}"
+
 # condition
 spel:
   scheduling: true        # 定时任务是否执行
@@ -62,6 +67,7 @@ workhours:
   formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 当前正式两类审批单
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
+  formUuidBackendExceptionLog: "FORM-20F89FE062FC4726B540975E9E269075NFWS"
   formUuidApproval: "FORM-DDA1683645954101890AF6BA723705F9M0XV"
   formUuidOtherApproval: "FORM-BA14F6322DBD470F8CF84CCE131DEA31TIJS"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
@@ -79,7 +85,7 @@ workhours:
   otherApprovalProcessCode: "TPROC--14E66UA1Q2R38711L6SYK62QXEYR3574VAEMM9"
   approvalOriginatorUserId: "${AKDS_APPROVAL_ORIGINATOR_USER_ID:477737964}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
-  approvalResubmitModifiedLookbackDays: 3
+  approvalResubmitModifiedLookbackDays: 7
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 # prd 月度封账:TimeCard 实时锁定 + 封账/启封提交前校验

+ 56 - 0
mjava-akdsbeisen/src/test/java/com/malk/controller/AdminEndpointGuardConfigTest.java

@@ -0,0 +1,56 @@
+package com.malk.controller;
+
+import org.junit.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class AdminEndpointGuardConfigTest {
+
+    @Test
+    public void disabledAdminApiShouldRemainHidden() throws Exception {
+        AdminEndpointGuardConfig.AdminEndpointInterceptor interceptor =
+                new AdminEndpointGuardConfig.AdminEndpointInterceptor(false, "configured-token");
+        MockHttpServletRequest request = request("configured-token");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        assertFalse(interceptor.preHandle(request, response, new Object()));
+        assertEquals(404, response.getStatus());
+    }
+
+    @Test
+    public void enabledAdminApiShouldRejectMissingOrWrongToken() throws Exception {
+        AdminEndpointGuardConfig.AdminEndpointInterceptor interceptor =
+                new AdminEndpointGuardConfig.AdminEndpointInterceptor(true, "configured-token");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        assertFalse(interceptor.preHandle(request("wrong-token"), response, new Object()));
+        assertEquals(403, response.getStatus());
+    }
+
+    @Test
+    public void enabledAdminApiShouldAcceptMatchingToken() throws Exception {
+        AdminEndpointGuardConfig.AdminEndpointInterceptor interceptor =
+                new AdminEndpointGuardConfig.AdminEndpointInterceptor(true, "configured-token");
+
+        assertTrue(interceptor.preHandle(
+                request("configured-token"), new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    public void blankConfiguredTokenShouldNeverAuthorize() {
+        assertFalse(AdminEndpointGuardConfig.tokenMatches("", ""));
+        assertFalse(AdminEndpointGuardConfig.tokenMatches("", "supplied"));
+    }
+
+    private static MockHttpServletRequest request(String token) {
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/workhours/sync");
+        if (token != null) {
+            request.addHeader(AdminEndpointGuardConfig.TOKEN_HEADER, token);
+        }
+        return request;
+    }
+}

+ 41 - 0
mjava-akdsbeisen/src/test/java/com/malk/controller/WorkHoursControllerTest.java

@@ -1,14 +1,36 @@
 package com.malk.controller;
 
 import org.junit.Test;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 
 import java.lang.reflect.Method;
+import java.util.Map;
 
 import static org.junit.Assert.assertEquals;
 
 public class WorkHoursControllerTest {
 
+    @Test
+    public void deleteAllShouldRequirePostAndExplicitConfirmation() throws Exception {
+        Method method = WorkHoursController.class.getMethod("deleteAll", String.class);
+
+        assertEquals("/delete-all", method.getAnnotation(PostMapping.class).value()[0]);
+        assertEquals(String.class, method.getParameterTypes()[0]);
+
+        Map<String, Object> rejected = new WorkHoursController().deleteAll("WRONG");
+        assertEquals(false, rejected.get("success"));
+    }
+
+    @Test
+    public void approvalResubmitShouldDefaultToDryRun() throws Exception {
+        Method method = ApprovalController.class.getMethod(
+                "resubmit", String.class, String.class, boolean.class);
+        RequestParam requestParam = method.getParameters()[2].getAnnotation(RequestParam.class);
+
+        assertEquals("true", requestParam.defaultValue());
+    }
+
     @Test
     public void cleanupAfterOfflineShouldDefaultToDryRun() throws Exception {
         Method method = WorkHoursController.class.getMethod("cleanupAfterOffline", boolean.class);
@@ -16,4 +38,23 @@ public class WorkHoursControllerTest {
 
         assertEquals("true", requestParam.defaultValue());
     }
+
+    @Test
+    public void allMaintenanceCleanupMethodsShouldDefaultToDryRun() throws Exception {
+        assertDryRunDefault("backfillCf", boolean.class);
+        assertDryRunDefault("backfillCompanyAttr", boolean.class);
+        assertDryRunDefault("cleanupAfterOffline", boolean.class);
+        assertDryRunDefault("cleanupBeforeHiredDate", boolean.class);
+
+        Method cleanupFuture = WorkHoursController.class.getMethod(
+                "cleanupFuture", String.class, boolean.class);
+        RequestParam requestParam = cleanupFuture.getParameters()[1].getAnnotation(RequestParam.class);
+        assertEquals("true", requestParam.defaultValue());
+    }
+
+    private static void assertDryRunDefault(String methodName, Class<?>... parameterTypes) throws Exception {
+        Method method = WorkHoursController.class.getMethod(methodName, parameterTypes);
+        RequestParam requestParam = method.getParameters()[0].getAnnotation(RequestParam.class);
+        assertEquals("true", requestParam.defaultValue());
+    }
 }

+ 23 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/personnel/impl/PersonnelSyncServiceImplTest.java

@@ -115,6 +115,29 @@ public class PersonnelSyncServiceImplTest {
         assertFalse(formData.containsKey(conf.getFieldHiredDate()));
     }
 
+    @Test
+    public void managerLookupFailuresShouldBeCountedForTimerRetry() throws Exception {
+        DDClient ddClient = mock(DDClient.class);
+        DDClient_Contacts contacts = mock(DDClient_Contacts.class);
+        PersonnelSyncConf conf = testConf();
+        when(ddClient.getAccessToken()).thenReturn("test-access-token");
+        when(contacts.getUserInfoById("test-access-token", "employee-1"))
+                .thenThrow(new RuntimeException("rate limited"));
+
+        PersonnelSyncServiceImpl service = new PersonnelSyncServiceImpl();
+        setField(service, "ddClient", ddClient);
+        setField(service, "ddClient_contacts", contacts);
+        setField(service, "conf", conf);
+        invokeNoArg(service, "initRateLimiters");
+
+        Map<String, Map> users = new LinkedHashMap<>();
+        Map<String, Object> user = new LinkedHashMap<>();
+        user.put("userid", "employee-1");
+        users.put("employee-1", user);
+
+        assertEquals(1, service.enrichManagers(users));
+    }
+
     private static PersonnelSyncConf testConf() {
         PersonnelSyncConf conf = new PersonnelSyncConf();
         conf.setYidaAppType("test-app");

+ 8 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java

@@ -102,4 +102,12 @@ class ApprovalWriteBackServiceTest {
 
         assertEquals(true, ApprovalWriteBackService.isOtherApprovalForm(formData));
     }
+
+    @Test
+    void shouldNeverTreatEmptyOrMalformedApprovalDetailsAsSuccess() {
+        assertEquals("全部失败", ApprovalWriteBackService.determineSyncStatus(0, 0));
+        assertEquals("全部失败", ApprovalWriteBackService.determineSyncStatus(1, 1));
+        assertEquals("部分失败", ApprovalWriteBackService.determineSyncStatus(3, 1));
+        assertEquals("全部成功", ApprovalWriteBackService.determineSyncStatus(3, 0));
+    }
 }

+ 112 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/BackendExceptionLogServiceTest.java

@@ -0,0 +1,112 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.workhours.BackendExceptionLog;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.core.env.Environment;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class BackendExceptionLogServiceTest {
+
+    @Test
+    void shouldUpsertStructuredAndSanitizedExceptionLog() {
+        YDClient ydClient = mock(YDClient.class);
+        Environment environment = mock(Environment.class);
+        when(environment.getActiveProfiles()).thenReturn(new String[]{"prod"});
+        BackendExceptionLogService service = service(ydClient, environment, true);
+
+        service.record(event("approval-1", "Authorization: Bearer abc token=secret123"));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        YDParam param = captor.getValue();
+        Map<String, Object> condition =
+                JSON.parseObject(param.getSearchCondition(), Map.class);
+        Map<String, Object> formData =
+                JSON.parseObject(param.getFormDataJson(), Map.class);
+
+        assertEquals("FORM-LOG", param.getFormUuid());
+        assertTrue(String.valueOf(condition.get("textField_g8r9qc3eb")).startsWith("LOG-"));
+        assertEquals("生产", formData.get("selectField_g8r9ssm3g"));
+        assertEquals("待处理", formData.get("selectField_g8r9t2n63"));
+        assertEquals("业务数据缺失", formData.get("selectField_g8r9vrxdx"));
+        assertEquals("是", formData.get("radioField_g8r917smpw"));
+        String detail = String.valueOf(formData.get("textareaField_g8r9165818"));
+        assertFalse(detail.contains("abc"));
+        assertFalse(detail.contains("secret123"));
+    }
+
+    @Test
+    void shouldKeepStableLogIdWhenOnlySummaryChanges() {
+        BackendExceptionLog first = event("approval-1", "first");
+        BackendExceptionLog second = event("approval-1", "second");
+        second.setSummary("新的异常摘要");
+
+        assertEquals(
+                BackendExceptionLogService.buildLogId(first),
+                BackendExceptionLogService.buildLogId(second));
+    }
+
+    @Test
+    void shouldSkipWriteWhenFormIsNotConfigured() {
+        YDClient ydClient = mock(YDClient.class);
+        Environment environment = mock(Environment.class);
+        BackendExceptionLogService service = service(ydClient, environment, false);
+
+        service.record(event("approval-1", "detail"));
+
+        verify(ydClient, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class));
+    }
+
+    private static BackendExceptionLogService service(
+            YDClient ydClient,
+            Environment environment,
+            boolean configureForm) {
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("APP");
+        conf.setYidaSystemToken("TOKEN");
+        if (configureForm) {
+            conf.setFormUuidBackendExceptionLog("FORM-LOG");
+        }
+        BackendExceptionLogService service = new BackendExceptionLogService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "environment", environment);
+        return service;
+    }
+
+    private static BackendExceptionLog event(String objectId, String detail) {
+        return BackendExceptionLog.builder()
+                .occurredAt(1788278400000L)
+                .category(BackendExceptionLogService.CATEGORY_BUSINESS_DATA_MISSING)
+                .sourceSystem(BackendExceptionLogService.SOURCE_AKDS_BACKEND)
+                .sourceModule("ApprovalWriteBackTimer")
+                .sourceAction("writeBack")
+                .sourceDescription("审批完成后回写工时汇总表")
+                .businessObjectType("工时审批单")
+                .businessObjectId(objectId)
+                .errorCode("APPROVAL_WRITEBACK_NOT_ALL_SUCCESS")
+                .summary("审批回写未全部成功")
+                .detail(detail)
+                .retryable(true)
+                .retryCount(1)
+                .build();
+    }
+}

+ 2 - 2
mjava-akdsbeisen/src/test/java/com/malk/timer/ReSubmitApprovalTimerTest.java

@@ -24,11 +24,11 @@ import static org.mockito.Mockito.when;
 class ReSubmitApprovalTimerTest {
 
     @Test
-    void shouldScheduleNoonFallbackAtTwelveFortyFive() throws NoSuchMethodException {
+    void shouldScheduleNoonFallbackAtTwelveFiftyFive() throws NoSuchMethodException {
         Method method = ReSubmitApprovalTimer.class.getDeclaredMethod("scanAndReSubmitAtNoon");
         Scheduled scheduled = method.getAnnotation(Scheduled.class);
 
-        assertEquals("0 45 12 * * ?", scheduled.cron());
+        assertEquals("0 55 12 * * ?", scheduled.cron());
     }
 
     @Test

+ 54 - 1
mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

@@ -1,6 +1,7 @@
 package com.malk.timer;
 
 import com.malk.service.workhours.WorkHoursCalcService;
+import com.malk.server.workhours.WHConf;
 import org.junit.Test;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -74,13 +75,36 @@ public class WorkHoursTimerScheduleTest {
 
         assertEquals(setOf("0 20 4 * * ?", "0 20 12 * * ?"), scheduledCrons(writeBack));
         assertEquals(setOf("0 20 5 * * ?"), scheduledCrons(resubmit));
-        assertEquals(setOf("0 45 12 * * ?"), scheduledCrons(noonResubmit));
+        assertEquals(setOf("0 55 12 * * ?"), scheduledCrons(noonResubmit));
+    }
+
+    @Test
+    public void approvalResubmitShouldDefaultToSevenDayRecoveryWindow() {
+        assertEquals(7, new WHConf().getApprovalResubmitModifiedLookbackDays());
     }
 
     @Test
     public void approvalFallbackTimersShouldRespectGlobalSchedulingSwitch() {
         assertSchedulingSwitch(ApprovalWriteBackTimer.class);
         assertSchedulingSwitch(ReSubmitApprovalTimer.class);
+        assertSchedulingSwitch(PersonnelSyncTimer.class);
+        assertSchedulingSwitch(BeisenSyncTimer.class);
+    }
+
+    @Test
+    public void personnelTimerShouldRetryWriteAndManagerLookupFailures() {
+        assertTrue(PersonnelSyncTimer.hasSyncFailures(null));
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("failed", 0);
+        stats.put("managerLookupFailed", 1);
+        assertTrue(PersonnelSyncTimer.hasSyncFailures(stats));
+
+        stats.put("managerLookupFailed", 0);
+        stats.put("failed", 1);
+        assertTrue(PersonnelSyncTimer.hasSyncFailures(stats));
+
+        stats.put("failed", 0);
+        assertFalse(PersonnelSyncTimer.hasSyncFailures(stats));
     }
 
     @Test
@@ -110,6 +134,35 @@ public class WorkHoursTimerScheduleTest {
         assertFalse(ApprovalWriteBackTimer.hasNextPage(20, 100, 2500, 99));
     }
 
+    @Test
+    public void approvalWriteBackShouldRetryEveryStatusExceptAllSuccess() {
+        assertTrue(ApprovalWriteBackTimer.shouldRetrySyncStatus(""));
+        assertTrue(ApprovalWriteBackTimer.shouldRetrySyncStatus("部分失败"));
+        assertTrue(ApprovalWriteBackTimer.shouldRetrySyncStatus("全部失败"));
+        assertTrue(ApprovalWriteBackTimer.shouldRetrySyncStatus("未知状态"));
+        assertFalse(ApprovalWriteBackTimer.shouldRetrySyncStatus("全部成功"));
+    }
+
+    @Test
+    public void approvalWriteBackShouldUseStatusFieldForEachApprovalType() {
+        WHConf conf = new WHConf();
+        conf.setApprovalSyncStatusField("bill-status-field");
+        conf.setOtherApprovalSyncStatusField("other-status-field");
+        ApprovalWriteBackTimer timer = new ApprovalWriteBackTimer();
+        ReflectionTestUtils.setField(timer, "whConf", conf);
+
+        assertEquals("bill-status-field", timer.resolveSyncStatusField("bill"));
+        assertEquals("other-status-field", timer.resolveSyncStatusField("other"));
+    }
+
+    @Test
+    public void approvalWriteBackShouldFailClosedWhenApprovalResultIsMissing() {
+        assertEquals(Integer.valueOf(1), ApprovalWriteBackTimer.resolveResultCode("agree"));
+        assertEquals(Integer.valueOf(0), ApprovalWriteBackTimer.resolveResultCode("refuse"));
+        assertEquals(null, ApprovalWriteBackTimer.resolveResultCode(null));
+        assertEquals(null, ApprovalWriteBackTimer.resolveResultCode("unknown"));
+    }
+
     private static void assertSchedulingSwitch(Class<?> timerClass) {
         ConditionalOnProperty condition = timerClass.getAnnotation(ConditionalOnProperty.class);
         assertEquals("enable", condition.prefix());

+ 3 - 0
mjava/pom.xml

@@ -17,6 +17,9 @@
     <properties>
         <maven.compiler.source>8</maven.compiler.source>
         <maven.compiler.target>8</maven.compiler.target>
+        <!-- prd 公共基础模块参与 AKDS reactor 构建时也必须执行测试 -->
+        <skipTests>false</skipTests>
+        <maven.test.skip>false</maven.test.skip>
     </properties>
 
     <dependencies>

+ 0 - 7
mjava/src/test/java/com/malk/McTest.java

@@ -7,10 +7,6 @@ import com.malk.utils.UtilString;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.context.junit4.SpringRunner;
 
 import java.math.BigDecimal;
 import java.time.Duration;
@@ -29,9 +25,6 @@ import java.util.Map;
  * @Rollback,回滚注解,配合事务注解@Transactional,来回滚事务。可以定义在测试类上,对整个类起作用,也可以对方法单独定义。默认值true,若junit需要写入设置为false
  */
 @Slf4j
-@ActiveProfiles({"dev"})
-@RunWith(SpringRunner.class)
-@SpringBootTest(classes = Boot.class)
 public class McTest {