From aae28a6b4b1c94425b8f1243b808a54d88a2545f Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Tue, 20 Oct 2026 12:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=8E=E4=BD=8F=E4=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E6=95=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/budwk/app/MainLauncher.java | 3 + .../app/base/constant/RedisConstant.java | 2 + .../impl/SysDataUnitPullServiceImpl.java | 5 +- .../manage/TrainSignUpApplyController.java | 18 + ...ainSignUpActivityStatisticsController.java | 21 +- .../impl/ProposalExportServiceImpl.java | 48 +- .../controller/HuaZhuManageController.java | 39 +- .../huazhu/dto/HuaZhuAuthTokenResultDTO.java | 18 + .../app/zhgh/huazhu/job/HuaZhuSyncJob.java | 43 + .../zhgh/huazhu/models/HuaZhuSyncRecord.java | 38 + .../zhgh/huazhu/models/HuaZhuSyncTask.java | 34 + .../zhgh/huazhu/param/HuaZhuSyncPageForm.java | 13 + .../huazhu/service/HuaZhuAuthService.java | 9 + .../huazhu/service/HuaZhuEmployeeService.java | 36 +- .../service/impl/HuaZhuAuthServiceImpl.java | 78 ++ .../impl/HuaZhuEmployeeServiceImpl.java | 976 ++++++++++++++++-- .../controller/CondolenceApplyController.java | 4 +- .../condolence/service/CondolenceService.java | 2 +- .../service/impl/CondolenceServiceImpl.java | 6 +- .../DifficultSubsidyApplyController.java | 27 + .../service/DifficultSubsidyService.java | 17 + .../impl/DifficultSubsidyServiceImpl.java | 162 ++- .../static/assets/platform/js/main.js | 36 +- .../platform/js/mixin/initTableMixins.js | 14 +- .../components/plugins/sysUpload/index.vue | 46 +- .../activity/trainSignUp/apply/applyForm.js | 149 ++- .../activity/trainSignUp/manage/basicForm.js | 85 +- .../views/platform/zhgh/huazhu/index.html | 291 ++++-- .../views/platform/zhgh/huazhu/info.js | 142 +++ .../staffbenefit/condolence/apply/index.html | 3 +- .../staffbenefit/condolence/read/index.html | 2 + .../difficultSubsidy/process/apply/index.html | 235 +++-- .../activity/trainSignUp/list/applyForm.js | 35 +- 33 files changed, 2228 insertions(+), 409 deletions(-) create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/dto/HuaZhuAuthTokenResultDTO.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/job/HuaZhuSyncJob.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncRecord.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncTask.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuSyncPageForm.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuAuthService.java create mode 100644 src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuAuthServiceImpl.java create mode 100644 src/main/resources/views/platform/zhgh/huazhu/info.js diff --git a/src/main/java/com/budwk/app/MainLauncher.java b/src/main/java/com/budwk/app/MainLauncher.java index dd975e4..9a40b8a 100644 --- a/src/main/java/com/budwk/app/MainLauncher.java +++ b/src/main/java/com/budwk/app/MainLauncher.java @@ -85,6 +85,9 @@ public class MainLauncher { init_task(); init_auth(); ioc.get(Globals.class); + // Globals 会清空并恢复系统任务,必须等它完成后再恢复华住未结束批次;新批次由按钮提交时创建调度。 + log.info("[华住同步][步骤=全局任务初始化完成] 开始检查待恢复批次"); + ioc.get(com.budwk.app.zhgh.huazhu.service.impl.HuaZhuEmployeeServiceImpl.class).restorePendingSyncTask(); ioc.get(FlowEngine.class); ioc.get(GlobalMessageSendService.class); diff --git a/src/main/java/com/budwk/app/base/constant/RedisConstant.java b/src/main/java/com/budwk/app/base/constant/RedisConstant.java index 6920246..cf8fe5d 100644 --- a/src/main/java/com/budwk/app/base/constant/RedisConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RedisConstant.java @@ -6,6 +6,8 @@ package com.budwk.app.base.constant; public class RedisConstant { //项目统一前缀 public final static String PLATFORM_REDIS_PREFIX = "budwk5mini:"; + /** 与原华住认证服务一致,缓存 HuaZhuAuthTokenResultDTO 的 JSON。 */ + public static final String REDIS_KEY_HUAZHU_AUTH_TOKEN = PLATFORM_REDIS_PREFIX + "hz:token:"; public final static String PLATFORM_REDIS_WKCACHE_PREFIX = PLATFORM_REDIS_PREFIX + "wkcache:"; //聊天室 diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUnitPullServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUnitPullServiceImpl.java index 1d0a704..0fa474f 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUnitPullServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUnitPullServiceImpl.java @@ -84,7 +84,8 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl implem } for (Sys_unit unit : units) { - unit.setParentId("0"); + // 当前系统学校根单位 ID 为 1,同步的二级单位统一挂在该节点下。 + unit.setParentId("1"); unit.setUnitLevel(2); unit.setUnitTypeCode(1); if (unitIds.contains(unit.getId())) { @@ -92,7 +93,7 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl implem } else { Map beanMap = BeanUtil.beanToMap(unit); beanMap.remove("child"); - sysUnitService.save(unit, "0"); + sysUnitService.save(unit, "1"); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java index c875314..0b6f72e 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java @@ -21,11 +21,13 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.cri.Static; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; @@ -370,11 +372,27 @@ public class TrainSignUpApplyController { } } + /** + * 接收本次报名信息并校验手机号必填及11位格式,PC 与 H5 共用此入口。 + * + * @param trainSignUpUser 报名参数:activityId 为活动ID,courseId 为课程ID,mobile 为必填的11位大陆手机号; + * 限额课程通过 activityCourseId 指定时段,mobileColumnsValue 为动态字段及其填写值列表 + * @return Result,code 为结果状态码,msg 为手机号校验或报名处理的结果说明 + */ @At + @Aop(TransAop.READ_COMMITTED) @ApiOperation("活动报名") @SaCheckPermission(value = {"trainSignUp.apply", "h5.trainSignUp.apply"}, mode = SaMode.OR) @SLog(tag = "品牌活动-活动报名", msg = "活动报名") public Result doSignUp(TrainSignUpUser trainSignUpUser) { + // 在进入报名处理前拦截缺失、空串及纯空白手机号,避免绕过页面必填校验。 + if (trainSignUpUser == null || StrUtil.isBlank(trainSignUpUser.getMobile())) { + return Result.error("请填写手机号"); + } + // 与 PC、H5 使用同一规则,仅接受1开头、第二位为3至9的11位数字。 + if (!trainSignUpUser.getMobile().matches("^1[3-9][0-9]{9}$")) { + return Result.error("请输入正确的11位手机号"); + } try { lock.lock(); boolean courseByUser = trainSignUpActivityService.isSignCourseByUser(trainSignUpUser.getCourseId(), SecurityUtil.getUserId()); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java index aebe340..1d4741f 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java @@ -22,7 +22,6 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.nutz.dao.Chain; @@ -188,6 +187,12 @@ public class TrainSignUpActivityStatisticsController { return Result.success(); } + /** + * 导出指定活动的报名人员,按课程分别生成工作表,包含手机号等基础信息及该课程的动态报名字段。 + * + * @param activityId 要导出报名人员的活动ID + * @param response 接收Excel下载内容的HTTP响应;方法无返回值,输出.xlsx格式的报名人员名单 + */ @At @Ok("void") @ApiOperation("导出签到名单") @@ -237,9 +242,10 @@ public class TrainSignUpActivityStatisticsController { for (TrainSignUpCourse c : courseList) { String k = c.getCourseName(); List v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList()); - ExportParams userExportParams = new ExportParams(); - userExportParams.setSheetName(k); - userExportParams.setType(ExcelType.HSSF); + ExportParams exportParams = new ExportParams(); + // 工作表参数与工作簿统一使用XSSF,确保导出的.xlsx文件格式一致。 + exportParams.setType(ExcelType.XSSF); + exportParams.setSheetName(k); List currentEntities = new ArrayList<>(); currentEntities.addAll(excelCommonExportEntity); @@ -285,20 +291,19 @@ public class TrainSignUpActivityStatisticsController { Map userExportMap = new HashMap<>(); userExportMap.put("name", k); - userExportParams.setType(ExcelType.HSSF); - userExportMap.put("title", userExportParams); + userExportMap.put("title", exportParams); userExportMap.put("entity", currentEntities); userExportMap.put("data", v); sheetsList.add(userExportMap); } - Workbook workbook = new HSSFWorkbook(); + Workbook workbook = new XSSFWorkbook(); for (Map map : sheetsList) { ExcelExportService service = new ExcelExportService(); service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List) map.get("entity"), (Collection) map.get("data")); } - CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response); + CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xlsx", workbook, response); } catch (Exception e) { e.printStackTrace(); } diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalExportServiceImpl.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalExportServiceImpl.java index 77e2733..857fa98 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalExportServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalExportServiceImpl.java @@ -557,6 +557,12 @@ public class ProposalExportServiceImpl extends BaseServiceImpl imp return ExcelExportUtil.exportExcel(exportParams, exportEntities, list); } + /** + * 按综合导出查询条件将提案逐份生成为 DOCX,再完整打包为 ZIP。 + * + * @param pageForm 综合导出页面的筛选参数,用于筛选提案名称、编号、单位等条件 + * @param response HTTP 响应,成功时直接输出 ZIP 二进制,包内每个 DOCX 对应一条提案 + */ @Override public void exportProposalStatisticsAsZip(ProposalQueryComprehensiveParam pageForm, HttpServletResponse response) { Sql sql = Sqls.create(""" @@ -574,32 +580,38 @@ public class ProposalExportServiceImpl extends BaseServiceImpl imp """); Cnd cnd = Cnd.NEW(); ProposalQueryComprehensiveParam.buildSearch(cnd, pageForm); + // 同一提案人可有多届代表记录,关联查询后按提案 ID 去重,避免重复生成同一提案。 + cnd.groupBy("info.id"); sql.setCondition(cnd); List list = listMap(sql); - try { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ZipOutputStream zipOutputStream = new ZipOutputStream(bos); - for (NutMap proposalRow : list) { - try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) { - exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream); - ZipEntry zipEntry = new ZipEntry(proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name")) + ".docx"); - zipOutputStream.putNextEntry(zipEntry); - docxByteArrayOutputStream.writeTo(zipOutputStream); - zipOutputStream.closeEntry(); - } catch (IOException e) { - // 处理单个文件生成失败的情况 - throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e); + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + LinkedHashSet zipEntryNames = new LinkedHashSet<>(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(bos)) { + for (NutMap proposalRow : list) { + try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) { + exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream); + String baseEntryName = proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name")); + String entryName = baseEntryName + ".docx"; + int sameNameIndex = 2; + // 不同提案可能同名,或清理特殊字符后重名;按最终文件名检查,保留全部提案。 + while (!zipEntryNames.add(entryName)) { + entryName = baseEntryName + "-" + sameNameIndex++ + ".docx"; + } + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + docxByteArrayOutputStream.writeTo(zipOutputStream); + zipOutputStream.closeEntry(); + } catch (Exception e) { + throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e); + } } } - zipOutputStream.finish(); - zipOutputStream.close(); + // 压缩流成功关闭、ZIP 目录完整写入后再下载,任一提案失败时不输出部分压缩包。 CommonDownloadUtil.download("提案汇总压缩包.zip", bos.toByteArray(), response); } catch (Exception e) { - e.printStackTrace(); + log.error("批量导出提案表 ZIP 失败", e); + throw new BaseException("批量导出提案表失败,请联系管理员"); } - - } diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java index b9e98ef..3c9bf80 100644 --- a/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java +++ b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.result.Result; import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm; import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.ioc.aop.Aop; @@ -37,12 +38,16 @@ public class HuaZhuManageController { @At @SaCheckPermission("huazhu.manage") public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) { - return Result.success(huaZhuEmployeeService.pageUsers(pageForm)); + try { + return Result.success(huaZhuEmployeeService.pageUsers(pageForm)); + } catch (Exception e) { + return Result.error("读取待推送员工失败,请稍后重试"); + } } /** * @param userIds 页面勾选员工的本项目用户主键数组,至少传入一个主键 - * @return Result,data 为逐人同步结果,含 userId、loginName、userName、success 和 message + * @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg */ @At @Aop(TransAop.READ_COMMITTED) @@ -51,6 +56,7 @@ public class HuaZhuManageController { if (userIds == null || userIds.length == 0) { return Result.error("请先选择需要同步的员工"); } + // 写入异常交给统一异常处理器;不在事务内部吞掉异常,保证批次与人员一起回滚。 return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds))); } @@ -65,24 +71,45 @@ public class HuaZhuManageController { return Result.success(NutMap.NEW().addv("description", huaZhuEmployeeService.getSyncRuleDescription())); } catch (IllegalStateException e) { return Result.error(e.getMessage()); + } catch (Exception e) { + return Result.error("读取同步规则失败,请稍后重试"); } } /** * 无需请求参数;由服务端按固定业务规则筛选全部待同步人员。 - * @return Result,data 为逐人同步结果,含 userId、loginName、userName、success 和 message;无人符合时为空列表 + * @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg;无人需要推送时返回中文错误 */ @At @Aop(TransAop.READ_COMMITTED) @SaCheckPermission("huazhu.manage") public Result syncRuleUsers() { + return Result.success(huaZhuEmployeeService.syncRuleUsers()); + } + + /** taskId 为可选批次主键;data 含 task、counts、tasks、running,不触发推送。 */ + @At + @SaCheckPermission("huazhu.manage") + public Result syncSummary(@Param("taskId") String taskId) { try { - return Result.success(huaZhuEmployeeService.syncRuleUsers()); - } catch (IllegalStateException e) { - return Result.error(e.getMessage()); + return Result.success(huaZhuEmployeeService.syncSummary(taskId)); + } catch (Exception e) { + return Result.error("读取推送进度失败,请稍后重试"); } } + /** pageForm 含分页及可选批次、状态、工号/姓名;data.list 为逐人回执,totalCount 为总条数。 */ + @At + @SaCheckPermission("huazhu.manage") + public Result recordPageData(@Valid HuaZhuSyncPageForm pageForm) { + try { + return Result.success(huaZhuEmployeeService.pageRecords(pageForm)); + } catch (Exception e) { + return Result.error("读取推送记录失败,请稍后重试"); + } + } + + /** userId 为本项目员工主键;data 为华住实时状态,msg 为可直接显示的中文错误。 */ @At @SaCheckPermission("huazhu.manage") public Result queryStatus(@Param("userId") String userId) { diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/dto/HuaZhuAuthTokenResultDTO.java b/src/main/java/com/budwk/app/zhgh/huazhu/dto/HuaZhuAuthTokenResultDTO.java new file mode 100644 index 0000000..06117cf --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/dto/HuaZhuAuthTokenResultDTO.java @@ -0,0 +1,18 @@ +package com.budwk.app.zhgh.huazhu.dto; + +import com.alibaba.fastjson.annotation.JSONField; +import lombok.Data; + +/** 沿用原系统的认证响应对象及字段映射,同时用于 Redis 缓存序列化。 */ +@Data +public class HuaZhuAuthTokenResultDTO { + /** 华住返回的裸 Token,调用接口时再拼接 Bearer 前缀。 */ + @JSONField(name = "access_token") + private String accessToken; + @JSONField(name = "token_type") + private String tokenType; + /** 华住返回的有效时间,单位秒;保持原 DTO 的字符串类型。 */ + @JSONField(name = "expires_in") + private String expiresIn; + private String scope; +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/job/HuaZhuSyncJob.java b/src/main/java/com/budwk/app/zhgh/huazhu/job/HuaZhuSyncJob.java new file mode 100644 index 0000000..41b40b4 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/job/HuaZhuSyncJob.java @@ -0,0 +1,43 @@ +package com.budwk.app.zhgh.huazhu.job; + +import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.log.Log; +import org.nutz.log.Logs; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; + +/** 固定后台入口;同一 Job 串行运行整批人员,页面离开不会中断执行。 */ +@IocBean +@DisallowConcurrentExecution +public class HuaZhuSyncJob implements Job { + private static final Log log = Logs.get(); + @Inject + private HuaZhuEmployeeService huaZhuEmployeeService; + + /** context 为 Quartz 触发上下文;仅调用业务服务,不在 Job 中编排人员或开启整批事务。 */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + String fireId = context == null ? "-" : context.getFireInstanceId(); + long started = System.nanoTime(); + log.infof("[华住同步][步骤=调度触发] 开始,fireInstanceId=%s", fireId); + if (huaZhuEmployeeService == null) { + // 工厂回退为普通实例会丢失 IOC 注入,此日志明确区别于触发器没有触发。 + log.error("[华住同步][步骤=检查服务注入] 失败,Job 未获得华住服务,请检查 Quartz 的 IOC JobFactory 配置"); + throw new JobExecutionException("华住后台服务未注入", false); + } + try { + huaZhuEmployeeService.runPendingTask(); + } catch (Exception e) { + log.errorf("[华住同步][步骤=后台调用] 异常,fireInstanceId=%s,类型=%s,栈=%s", fireId, + e.getClass().getName(), java.util.Arrays.toString(e.getStackTrace())); + // 不携带可能含认证信息的原异常消息,不立即重发推送。 + throw new JobExecutionException("华住后台调用异常,请查看阶段日志", false); + } finally { + log.infof("[华住同步][步骤=调度结束] fireInstanceId=%s,耗时=%dms", fireId, (System.nanoTime() - started) / 1_000_000L); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncRecord.java b/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncRecord.java new file mode 100644 index 0000000..58554ce --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncRecord.java @@ -0,0 +1,38 @@ +package com.budwk.app.zhgh.huazhu.models; + +import lombok.Data; +import org.nutz.dao.entity.annotation.*; + +/** 每次推送的人员快照与回执;不保存认证 token、卡密码或请求正文。 */ +@Data +@Table("huazhu_sync_record") +@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}") +// 同批次及同一员工均可存在多条相同状态的记录;这两个索引仅用于查询,不能限制唯一性。 +@TableIndexes({@Index(name = "IDX_HUAZHU_TASK_STATUS", fields = {"taskId", "status"}, unique = false), + @Index(name = "IDX_HUAZHU_USER_STATUS", fields = {"userId", "status"}, unique = false)}) +public class HuaZhuSyncRecord { + @Name + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + private String taskId; + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + @Column + private String loginName; + @Column + private String userName; + /** PENDING 待推送、SENDING 发送中、SUCCESS 推送成功、EXISTS 华住已存在、FAILED 明确失败、UNKNOWN 待核实。 */ + @Column + private String status; + @Column + @ColDefine(type = ColType.TEXT) + private String message; + @Column + private Long submittedAt; + @Column + private Long finishedAt; +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncTask.java b/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncTask.java new file mode 100644 index 0000000..7bc8f87 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/models/HuaZhuSyncTask.java @@ -0,0 +1,34 @@ +package com.budwk.app.zhgh.huazhu.models; + +import lombok.Data; +import org.nutz.dao.entity.annotation.*; + +/** 华住推送批次。activeSlot 唯一约束防止管理员重复提交;后台按当前单实例 Quartz 配置串行执行。 */ +@Data +@Table("huazhu_sync_task") +@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}") +@TableIndexes({@Index(name = "UK_HUAZHU_ACTIVE_SLOT", fields = {"activeSlot"}, unique = true)}) +public class HuaZhuSyncTask { + @Name + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + private String id; + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + private String activeSlot; + /** SELECTED 为勾选同步,RULE 为规则同步。 */ + @Column + private String source; + /** RUNNING 为已提交待后台处理或处理中,COMPLETED 为遍历结束,FAILED 为批次异常中止。 */ + @Column + private String status; + @Column + private Integer total; + @Column + private Long submittedAt; + @Column + private Long finishedAt; + /** 兼容旧表保留的租约字段;现由固定 Quartz Job 串行执行,不再按逐人租约领取。 */ + @Column + private Long leaseUntil; +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuSyncPageForm.java b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuSyncPageForm.java new file mode 100644 index 0000000..9a5492a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuSyncPageForm.java @@ -0,0 +1,13 @@ +package com.budwk.app.zhgh.huazhu.param; + +import com.budwk.app.base.param.PageForm; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 推送记录分页:taskId 为空查全部批次,status 为空查全部状态,searchKeyword 匹配工号或姓名。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HuaZhuSyncPageForm extends PageForm { + private String taskId; + private String status; +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuAuthService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuAuthService.java new file mode 100644 index 0000000..09e1813 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuAuthService.java @@ -0,0 +1,9 @@ +package com.budwk.app.zhgh.huazhu.service; + +import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO; + +/** 华住认证,保留原系统 getToken 接口。 */ +public interface HuaZhuAuthService { + /** 无参数;优先读取缓存,否则请求认证。返回 DTO,accessToken 为裸 Token,失败抛出异常。 */ + HuaZhuAuthTokenResultDTO getToken(); +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java index e7ee66d..f6b0a06 100644 --- a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java @@ -3,7 +3,9 @@ package com.budwk.app.zhgh.huazhu.service; import com.budwk.app.base.page.Pagination; import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; -import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncTask; +import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncRecord; +import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm; import org.nutz.lang.util.NutMap; import java.util.List; @@ -25,9 +27,9 @@ public interface HuaZhuEmployeeService { * 将选中的本项目员工同步到华住。 * * @param userIds 本项目用户主键列表 - * @return 每位员工的同步结果 + * @return 已创建批次,含 id、total、status 和 submittedAt;实际推送在后台进行 */ - List syncUsers(List userIds); + HuaZhuSyncTask syncUsers(List userIds); /** * 获取规则同步的中文说明,无需传参;单位名称从当前单位表读取,缺失时拒绝继续同步。 @@ -39,9 +41,33 @@ public interface HuaZhuEmployeeService { /** * 同步全部符合规则且未删除的员工,无需传参,不受页面搜索、分页或勾选范围限制。 * - * @return 每位员工的工号、姓名、success 成功标记和 message 结果说明;无人符合时返回空列表 + * @return 已创建批次;无人需要推送时抛出中文业务异常 */ - List syncRuleUsers(); + HuaZhuSyncTask syncRuleUsers(); + + /** 无参数、无返回值;后台顺序推送一个批次,明确认证失败时刷新 Token 并重试当前人员一次,仍失败则停止批次。 */ + void runPendingTask(); + + /** 无参数;返回活动批次或 null,将上次中断的 SENDING 转待核实,禁止自动重发。 */ + HuaZhuSyncTask prepareBatch(); + + /** recordId 为记录主键、taskId 为批次主键;返回是否将待推送记录成功标为发送中。 */ + boolean markSending(String recordId, String taskId); + + /** record 为发送结果;单独事务保存,状态不匹配或保存失败时抛出异常。 */ + void finishRecord(HuaZhuSyncRecord record); + + /** taskId 为完成批次主键;确认无待处理人员后结束批次,无返回值。 */ + void completeBatch(String taskId); + + /** taskId 为异常批次、reason 为中文原因;保留已完成结果,结束剩余记录及批次。 */ + void failBatch(String taskId, String reason); + + /** taskId 可为空,返回最近/指定批次、状态计数、最近批次及后台是否运行。 */ + NutMap syncSummary(String taskId); + + /** pageForm 提供分页及可选批次、状态、关键词;返回每人的持久化推送记录。 */ + Pagination pageRecords(HuaZhuSyncPageForm pageForm); /** * 查询指定本项目员工在华住的实时状态。 diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuAuthServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuAuthServiceImpl.java new file mode 100644 index 0000000..d23997d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuAuthServiceImpl.java @@ -0,0 +1,78 @@ +package com.budwk.app.zhgh.huazhu.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONUtil; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; +import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO; +import com.budwk.app.zhgh.huazhu.service.HuaZhuAuthService; +import org.apache.commons.lang3.StringUtils; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.integration.jedis.RedisService; +import org.nutz.log.Log; +import org.nutz.log.Logs; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** 原认证方法仅增加阶段日志和响应关闭,认证参数、DTO 转换及缓存内容保持原写法。 */ +@IocBean +public class HuaZhuAuthServiceImpl implements HuaZhuAuthService { + private static final Log log = Logs.get(); + @Inject + private RedisService redisService; + + /** 无参数;返回原 DTO。缓存 58 分钟,认证请求 20 秒超时,日志不输出认证头或 Token。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public HuaZhuAuthTokenResultDTO getToken() { + String step = "读取Token缓存"; + long started = System.nanoTime(); + try { + log.info("[华住认证][步骤=读取Token缓存] 开始"); + String token = redisService.get(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN); + if (StringUtils.isNotBlank(token)) { + step = "缓存转Token对象"; + log.info("[华住认证][步骤=缓存转Token对象] 开始,命中缓存"); + return JSONUtil.toBean(token, HuaZhuAuthTokenResultDTO.class); + } + step = "构建Basic认证请求"; + log.info("[华住认证][步骤=构建Basic认证请求] 开始"); + String url = String.format("%s%s", HuaZhuConstant.OAUTH2_URL, "/oauth/token?scope=ALL&grant_type=client_credentials"); + String sign = StringUtils.join("Basic ", Base64.getEncoder().encodeToString(String.format("%s:%s", HuaZhuConstant.CLIENT_ID, HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8))); + step = "请求华住Token"; + log.info("[华住认证][步骤=请求华住Token] 开始,超时=20000ms"); + try (HttpResponse response = HttpRequest.post(url).header("Authorization", sign).timeout(20000).execute()) { + log.infof("[华住认证][步骤=请求华住Token] 收到响应,HTTP=%d", response.getStatus()); + if (response.isOk()) { + step = "读取并转换认证响应"; + log.info("[华住认证][步骤=读取并转换认证响应] 开始"); + String body = response.body(); + HuaZhuAuthTokenResultDTO tokenResultDTO = JSONUtil.toBean(body, HuaZhuAuthTokenResultDTO.class); + String newToken = tokenResultDTO.getAccessToken(); + if (StrUtil.isBlank(newToken)) { + throw new RuntimeException("请求华住认证接口失败,返回的Token为空"); + } + step = "缓存Token对象"; + log.info("[华住认证][步骤=缓存Token对象] 开始,有效期=3480秒"); + redisService.setex(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN, 60 * 58, JSONUtil.toJsonStr(tokenResultDTO)); + return tokenResultDTO; + } else { + throw new RuntimeException("请求华住认证接口失败,状态码:" + response.getStatus()); + } + } + } catch (RuntimeException e) { + // 认证异常原文可能包含响应正文,只记录类型和位置,业务层另行返回受控中文原因。 + log.errorf("[华住认证][步骤=%s] 失败,异常类型=%s,位置=%s", step, e.getClass().getName(), + e.getStackTrace().length == 0 ? "无" : e.getStackTrace()[0]); + throw e; + } finally { + log.infof("[华住认证][步骤=%s] 调用结束,耗时=%dms", step, (System.nanoTime() - started) / 1_000_000L); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java index fe99cf6..b7d4123 100644 --- a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java @@ -1,18 +1,31 @@ package com.budwk.app.zhgh.huazhu.service.impl; import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONObject; import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; -import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncTask; +import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncRecord; +import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm; +import com.budwk.app.zhgh.huazhu.job.HuaZhuSyncJob; +import org.nutz.dao.Chain; +import org.nutz.ioc.Ioc; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO; +import com.budwk.app.zhgh.huazhu.service.HuaZhuAuthService; +import org.quartz.*; +import org.nutz.json.Json; +import org.nutz.log.Log; +import org.nutz.log.Logs; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.ioc.aop.Aop; import org.nutz.dao.Cnd; @@ -24,21 +37,56 @@ import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; import org.nutz.integration.jedis.RedisService; -import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; import java.util.ArrayList; -import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.LinkedHashSet; +import java.util.concurrent.atomic.AtomicBoolean; /** * 华住员工同步与状态查询实现。 */ -@IocBean(args = {"refer:dao"}) +@IocBean(args = {"refer:dao"}, create = "init") public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { - private static final String TOKEN_CACHE_KEY = "zhgh:huazhu:oauth-token"; - // 这两个单位的在岗人员还需满足非自聘条件;说明文字和筛选使用同一组单位主键。 - private static final List RULE_UNIT_IDS = List.of("110100", "116110"); + private static final Log log = Logs.get(); + private static final String TOKEN_CACHE_KEY = RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN; + private static final JobKey SYNC_JOB_KEY = new JobKey("huazhuEmployeeSync", "huazhu"); + private static final TriggerKey SYNC_TRIGGER_KEY = new TriggerKey("huazhuEmployeeSync", "huazhu"); + // 这些单位的在岗人员还需满足非自聘条件;说明文字和筛选使用同一组单位主键。 +// private static final List RULE_UNIT_IDS = List.of("110100", "116110"); + private static final List RULE_UNIT_IDS = List.of("110100"); + + private static final String ACTIVE_SLOT = "huazhu"; + private static final String PENDING_CONDITION = " NOT EXISTS (SELECT 1 FROM huazhu_sync_record r " + + "WHERE r.userId = u.id AND r.status <> 'FAILED') "; + private static final String TASK_STATUS_INDEX = "IDX_HUAZHU_TASK_STATUS"; + private static final String USER_STATUS_INDEX = "IDX_HUAZHU_USER_STATUS"; + // 表及索引初始化成功后才允许提交;调度在按钮提交时确保存在,启动后仅恢复未结束批次。 + private volatile boolean syncReady; + private volatile String syncUnavailableMessage = "华住后台推送正在初始化,请稍后重试"; + + @Inject + private Scheduler scheduler; + + @Inject + private Ioc ioc; + + @Inject + private HuaZhuAuthService huaZhuAuthService; + + // 当前项目 Quartz 为单实例配置;Job 禁止并发,此标记再保护直接调用服务的情况。 + private final AtomicBoolean executing = new AtomicBoolean(); + private volatile long lastTriggeredAt; + private volatile String executingTaskId; private final Dao dao; @@ -49,17 +97,283 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { this.dao = dao; } + /** + * 按实体创建本模块的两张记录表并修复旧版错误唯一索引,不在 IOC 初始化时注册调度。 + * 全局任务初始化会清空 Quartz;后台注册由按钮提交或全局初始化后的批次恢复负责。 + */ + @Aop(TransAop.READ_COMMITTED) + public void init() { + long started = System.nanoTime(); + String step = "检查旧调度"; + log.info("[华住同步][步骤=初始化] 开始"); + syncReady = false; + syncUnavailableMessage = "华住后台推送正在初始化,请稍后重试"; + JobKey key = SYNC_JOB_KEY; + String failureMessage = "华住后台任务暂停失败,请联系管理员检查调度器后重启应用"; + try { + // 暂停数据库中已有的触发器;校验或 DDL 失败时保持暂停,避免继续写入错误索引。 + log.infof("[华住同步][步骤=%s] 开始,任务=%s", step, key); + if (scheduler.checkExists(key)) { + step = "暂停旧调度"; + log.infof("[华住同步][步骤=%s] 开始,任务=%s", step, key); + scheduler.pauseJob(key); + log.infof("[华住同步][步骤=%s] 调用完成,任务=%s", step, key); + } else { + log.info("[华住同步][步骤=检查旧调度] 完成,没有旧任务"); + } + failureMessage = "华住推送记录表初始化失败,请联系管理员检查数据库建表权限及表结构后重启应用"; + step = "检查批次表"; + log.infof("[华住同步][步骤=%s] 开始,已有表保持不变", step); + dao.create(HuaZhuSyncTask.class, false); + log.infof("[华住同步][步骤=%s] 完成", step); + step = "检查人员记录表"; + log.infof("[华住同步][步骤=%s] 开始,已有表保持不变", step); + dao.create(HuaZhuSyncRecord.class, false); + log.infof("[华住同步][步骤=%s] 完成", step); + // MySQL DDL 会隐式提交,不能依赖本方法的事务回滚;每次 ALTER 后必须重读验证。 + failureMessage = "华住推送记录索引校验或修复失败,请联系管理员检查索引定义及数据库 ALTER 权限后重启应用"; + step = "校验记录索引"; + log.infof("[华住同步][步骤=%s] 开始", step); + dao.run(this::repairRecordIndexes); + log.infof("[华住同步][步骤=%s] 完成", step); + syncReady = true; + log.infof("[华住同步][步骤=初始化] 完成,就绪=%s,耗时=%dms", syncReady, (System.nanoTime() - started) / 1_000_000L); + } catch (Exception e) { + // 华住模块失败只停用推送,不向 IOC 抛出,避免阻断 MainLauncher 和其他业务启动。 + syncUnavailableMessage = failureMessage; + log.errorf("[华住同步][步骤=%s] 失败,后台保持停用,异常类型=%s,耗时=%dms", step, e.getClass().getName(), (System.nanoTime() - started) / 1_000_000L); + try { + // 注册或恢复也可能在调度器中部分生效;异常路径再次暂停,保持失败关闭。 + log.infof("[华住同步][步骤=失败后检查调度] 开始,任务=%s", key); + if (scheduler.checkExists(key)) { + log.infof("[华住同步][步骤=失败后暂停调度] 开始,任务=%s", key); + scheduler.pauseJob(key); + log.info("[华住同步][步骤=失败后暂停调度] 调用完成"); + } else { + log.info("[华住同步][步骤=失败后检查调度] 完成,没有已注册任务"); + } + } catch (Exception pauseFailure) { + log.errorf("[华住同步][步骤=失败后暂停调度] 失败,异常类型=%s", pauseFailure.getClass().getName()); + e.addSuppressed(pauseFailure); + } + log.error("华住推送索引校验或后台任务初始化失败,后台推送保持停用", e); + } + } + + /** + * 无参数、无返回值;仅由启动器在 Globals 初始化完成后调用。 + * 有未结束批次才恢复后台任务,无批次则等用户点击提交;失败只记录日志,不阻断其他模块启动。 + */ + @Aop(TransAop.READ_COMMITTED) + public void restorePendingSyncTask() { + if (!syncReady) { + log.warnf("[华住同步][步骤=恢复未结束批次] 跳过,%s", syncUnavailableMessage); + return; + } + try { + log.info("[华住同步][步骤=恢复未结束批次] 开始检查活动批次"); + if (dao.count(HuaZhuSyncTask.class, Cnd.where("activeSlot", "=", ACTIVE_SLOT)) == 0) { + log.info("[华住同步][步骤=恢复未结束批次] 没有未结束批次,等待页面提交时创建后台任务"); + return; + } + ensureSyncJob(); + log.info("[华住同步][步骤=恢复未结束批次] 后台任务已就绪,等待调度器读取已保存人员"); + } catch (Exception e) { + logFailure("恢复未结束批次", null, null, e); + } + } + + /** + * 无参数、无返回值;提交两个同步入口共用,确保固定 Job 和触发器可用。 + * 正常或正在执行的任务直接复用,缺失/异常时才注册;同步锁防止同进程并发点击重复注册。 + * 这里只准备后台调度,实际推送由 Job 在批次及人员事务提交后读取执行。 + */ + @Aop(TransAop.READ_COMMITTED) + public synchronized void ensureSyncJob() { + if (!syncReady) { + throw new BaseException(syncUnavailableMessage); + } + log.info("[华住同步][步骤=确保后台任务可用] 开始"); + try { + JobDetail job = scheduler.getJobDetail(SYNC_JOB_KEY); + Trigger trigger = scheduler.getTrigger(SYNC_TRIGGER_KEY); + Trigger.TriggerState state = scheduler.getTriggerState(SYNC_TRIGGER_KEY); + if (job != null && HuaZhuSyncJob.class.equals(job.getJobClass()) + && trigger instanceof CronTrigger && SYNC_JOB_KEY.equals(trigger.getJobKey()) + && "0/2 * * * * ?".equals(((CronTrigger) trigger).getCronExpression()) + && trigger.getNextFireTime() != null + && (state == Trigger.TriggerState.NORMAL || state == Trigger.TriggerState.BLOCKED)) { + log.infof("[华住同步][步骤=确保后台任务可用] 复用已有任务,状态=%s", state); + return; + } + log.infof("[华住同步][步骤=确保后台任务可用] 任务缺失或异常,准备创建,原状态=%s", state); + registerSyncJob(); + } catch (Exception e) { + logFailure("确保后台任务可用", null, null, e); + throw new BaseException("创建华住后台推送任务失败,请查看调度日志后重试"); + } + } + + /** + * 只更新华住固定 Job 及其触发器;旧 Job 存在不等于触发器可用。 + * 提交或恢复批次时写入执行类、两秒 cron 并恢复 ERROR/PAUSED,其他业务调度不受影响。 + */ + @Aop(TransAop.READ_COMMITTED) + public void registerSyncJob() throws SchedulerException { + log.infof("[华住同步][步骤=检查调度器] 开始,名称=%s,已启动=%s,待机=%s,已关闭=%s", + scheduler.getSchedulerName(), scheduler.isStarted(), scheduler.isInStandbyMode(), scheduler.isShutdown()); + if (scheduler.isShutdown()) { + throw new SchedulerException("华住后台调度器已关闭"); + } + for (Trigger old : scheduler.getTriggersOfJob(SYNC_JOB_KEY)) { + log.infof("[华住同步][步骤=检查旧触发器] key=%s,状态=%s,上次触发=%s,下次触发=%s", + old.getKey(), scheduler.getTriggerState(old.getKey()), old.getPreviousFireTime(), old.getNextFireTime()); + if (!SYNC_TRIGGER_KEY.equals(old.getKey())) { + // 只移除本 Job 的旧触发器,避免同一批次被多套 cron 反复唤醒。 + scheduler.unscheduleJob(old.getKey()); + } + } + log.info("[华住同步][步骤=注册后台调度] 开始,cron=0/2 * * * * ?"); + JobDetail job = JobBuilder.newJob(HuaZhuSyncJob.class).withIdentity(SYNC_JOB_KEY).build(); + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(SYNC_TRIGGER_KEY).forJob(SYNC_JOB_KEY) + .withSchedule(CronScheduleBuilder.cronSchedule("0/2 * * * * ?").withMisfireHandlingInstructionDoNothing()).build(); + scheduler.scheduleJob(job, Collections.singleton(trigger), true); + scheduler.resetTriggerFromErrorState(SYNC_TRIGGER_KEY); + scheduler.resumeJob(SYNC_JOB_KEY); + Trigger stored = scheduler.getTrigger(SYNC_TRIGGER_KEY); + Trigger.TriggerState state = scheduler.getTriggerState(SYNC_TRIGGER_KEY); + // 调度器已启动时可能在核验前立即执行;禁止并发 Job 的 BLOCKED 表示正在执行,属于正常状态。 + if (stored == null || stored.getNextFireTime() == null + || (state != Trigger.TriggerState.NORMAL && state != Trigger.TriggerState.BLOCKED)) { + throw new SchedulerException("华住触发器不可用,状态=" + state); + } + log.infof("[华住同步][步骤=核验触发器] 完成,状态=%s,下次触发=%s,等待应用调度器启动后执行", + state, stored.getNextFireTime()); + } + + /** + * 仅修复当前 MySQL 数据库内两个固定查询索引;不接收外部表名、索引名或 SQL 参数。 + * 正确索引直接跳过;旧唯一索引以同一条 ALTER 删除并重建,避免产生中间缺失状态。 + * 主键、活动槽位唯一索引及其他索引均不在白名单中;无法识别的定义停止初始化。 + */ + @Aop(TransAop.READ_COMMITTED) + private void repairRecordIndexes(Connection connection) throws SQLException { + log.info("[华住同步][步骤=确认数据库类型] 开始"); + if (!"MySQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName())) { + throw new SQLException("华住推送索引校验仅支持当前项目使用的 MySQL 数据库"); + } + log.info("[华住同步][步骤=确认数据库类型] 完成,MySQL"); + log.info("[华住同步][步骤=确认当前数据库] 开始"); + String catalog = connection.getCatalog(); + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT DATABASE()")) { + // JDBC catalog 与当前 SQL 数据库必须一致,禁止误修其他连接或其他数据库中的同名表。 + if (StrUtil.isBlank(catalog) || !result.next() || !catalog.equals(result.getString(1))) { + throw new SQLException("无法确认华住推送记录表所在的当前数据库"); + } + } + log.info("[华住同步][步骤=确认当前数据库] 完成,连接与当前数据库一致"); + Map indexes = readRecordIndexes(connection); + for (String name : List.of(TASK_STATUS_INDEX, USER_STATUS_INDEX)) { + log.infof("[华住同步][步骤=检查单个索引] 开始,索引=%s", name); + if (Boolean.FALSE.equals(indexes.get(name))) { + log.infof("[华住同步][步骤=检查单个索引] 跳过修复,索引=%s,已是正确普通索引", name); + continue; + } + boolean existing = indexes.containsKey(name); + // SQL 为完整固定白名单,绝不将 JDBC 元数据中的名称拼接进 DDL。 + String ddl; + if (TASK_STATUS_INDEX.equals(name)) { + ddl = existing ? "ALTER TABLE `huazhu_sync_record` DROP INDEX `IDX_HUAZHU_TASK_STATUS`, ADD INDEX `IDX_HUAZHU_TASK_STATUS` (`taskId`, `status`)" + : "ALTER TABLE `huazhu_sync_record` ADD INDEX `IDX_HUAZHU_TASK_STATUS` (`taskId`, `status`)"; + } else { + ddl = existing ? "ALTER TABLE `huazhu_sync_record` DROP INDEX `IDX_HUAZHU_USER_STATUS`, ADD INDEX `IDX_HUAZHU_USER_STATUS` (`userId`, `status`)" + : "ALTER TABLE `huazhu_sync_record` ADD INDEX `IDX_HUAZHU_USER_STATUS` (`userId`, `status`)"; + } + log.infof("[华住同步][步骤=执行索引修复] 开始,索引=%s,操作=%s", name, existing ? "唯一索引改为普通索引" : "补充缺失普通索引"); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(ddl); + } catch (SQLException e) { + // 多实例同时启动时,另一实例可能已修复;只有重读确认目标完全正确才接受竞争结果。 + log.warnf("[华住同步][步骤=索引修复后复核] 开始,索引=%s,DDL异常类型=%s", name, e.getClass().getName()); + indexes = readRecordIndexes(connection); + if (!Boolean.FALSE.equals(indexes.get(name))) { + log.errorf("[华住同步][步骤=索引修复后复核] 失败,索引=%s,尚未修复", name); + throw e; + } + log.infof("[华住同步][步骤=索引修复后复核] 完成,索引=%s,其他实例已修复", name); + } + log.infof("[华住同步][步骤=读取修复后索引] 开始,索引=%s", name); + indexes = readRecordIndexes(connection); + if (!Boolean.FALSE.equals(indexes.get(name))) { + throw new SQLException("华住推送记录索引修复后校验失败:" + name); + } + log.infof("[华住同步][步骤=执行索引修复] 完成,索引=%s,已确认普通索引", name); + } + log.info("[华住同步][步骤=最终复核两个索引] 开始"); + indexes = readRecordIndexes(connection); + if (!Boolean.FALSE.equals(indexes.get(TASK_STATUS_INDEX)) || !Boolean.FALSE.equals(indexes.get(USER_STATUS_INDEX))) { + throw new SQLException("华住推送记录的查询索引尚未全部就绪"); + } + log.info("[华住同步][步骤=最终复核两个索引] 完成"); + } + + /** + * 返回白名单索引的唯一性(true 表示旧错误定义,false 表示正确);缺失索引不加入结果。 + * SHOW INDEX 同时检查前缀、表达式、列顺序和索引类型,避免只按名称删除未知自定义索引。 + */ + private Map readRecordIndexes(Connection connection) throws SQLException { + log.info("[华住同步][步骤=读取记录索引元数据] 开始"); + Map indexes = new HashMap<>(); + Map> columns = new HashMap<>(); + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SHOW INDEX FROM `huazhu_sync_record`")) { + while (result.next()) { + String actualName = result.getString("Key_name"); + String name = TASK_STATUS_INDEX.equalsIgnoreCase(actualName) ? TASK_STATUS_INDEX + : USER_STATUS_INDEX.equalsIgnoreCase(actualName) ? USER_STATUS_INDEX : null; + if (name == null) { + continue; + } + int nonUnique = result.getInt("Non_unique"); + boolean missingUniqueFlag = result.wasNull(); + int position = result.getInt("Seq_in_index"); + String column = result.getString("Column_name"); + if (missingUniqueFlag || (nonUnique != 0 && nonUnique != 1) || position < 1 || position > 2 + || column == null || !"huazhu_sync_record".equalsIgnoreCase(result.getString("Table")) + || result.getObject("Sub_part") != null || !"BTREE".equalsIgnoreCase(result.getString("Index_type")) + || !"A".equalsIgnoreCase(result.getString("Collation"))) { + throw new SQLException("华住推送记录存在未知索引定义,未执行自动修改:" + name); + } + boolean unique = nonUnique == 0; + Boolean previousUnique = indexes.putIfAbsent(name, unique); + Map indexColumns = columns.computeIfAbsent(name, ignored -> new TreeMap<>()); + if ((previousUnique != null && previousUnique != unique) || indexColumns.putIfAbsent(position, column) != null) { + throw new SQLException("华住推送记录索引元数据不一致,未执行自动修改:" + name); + } + } + } + for (Map.Entry> entry : columns.entrySet()) { + String firstColumn = TASK_STATUS_INDEX.equals(entry.getKey()) ? "taskId" : "userId"; + Map indexColumns = entry.getValue(); + if (indexColumns.size() != 2 || !firstColumn.equalsIgnoreCase(indexColumns.get(1)) + || !"status".equalsIgnoreCase(indexColumns.get(2))) { + throw new SQLException("华住推送记录索引字段或顺序不符,未执行自动修改:" + entry.getKey()); + } + } + log.infof("[华住同步][步骤=读取记录索引元数据] 完成,识别到目标索引=%d个", indexes.size()); + return indexes; + } + @Override public Pagination pageUsers(HuaZhuEmployeePageForm pageForm) { String keyword = StrUtil.trim(pageForm.getSearchKeyword()); - String condition = " WHERE u.delFlag = 0 "; + String condition = " WHERE u.delFlag = 0 AND " + PENDING_CONDITION; if (StrUtil.isNotBlank(keyword)) { condition += " AND (u.loginname LIKE @keyword OR u.username LIKE @keyword) "; } Sql countSql = Sqls.create("SELECT COUNT(1) FROM sys_user u" + condition); Sql listSql = Sqls.create("SELECT u.id, u.loginname, u.username, unit.name AS unitName " - + "FROM sys_user u LEFT JOIN sys_unit unit ON unit.id = u.unitId " + condition - + "ORDER BY u.createdAt DESC"); + + "FROM sys_user u LEFT JOIN sys_unit unit ON unit.id = u.unitId " + condition + + "ORDER BY u.createdAt DESC"); if (StrUtil.isNotBlank(keyword)) { String likeKeyword = "%" + keyword + "%"; countSql.setParam("keyword", likeKeyword); @@ -75,74 +389,502 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { } /** - * 逐人推送,人员已删除、缺少工号或接口失败均记录为该人员失败,继续处理其余人员。 - * @param userIds 待同步的本项目用户主键列表 - * @return 每位员工的标识、success 成功标记和 message 结果说明 + * userIds 为勾选人员主键;一次查询人员快照并创建批次,返回批次信息,不在请求线程推送。 + * 已推送、已排队或待核实的人员不会重复加入,明确失败的人员可以重新提交。 */ @Override @Aop(TransAop.READ_COMMITTED) - public List syncUsers(List userIds) { - if (userIds == null || userIds.isEmpty()) { - return Collections.emptyList(); + public HuaZhuSyncTask syncUsers(List userIds) { + log.infof("[华住同步][步骤=校验勾选参数] 开始,选择数量=%d", userIds == null ? 0 : userIds.size()); + if (userIds == null || userIds.isEmpty() || userIds.stream().anyMatch(StrUtil::isBlank)) { + log.warn("[华住同步][步骤=校验勾选参数] 拒绝提交,未选择人员或包含空主键"); + throw new BaseException("请先选择需要同步的员工"); } - List results = new ArrayList<>(); - for (String userId : userIds) { - HuaZhuSyncResult result = new HuaZhuSyncResult().setUserId(userId); - try { - Sys_user user = findAvailableUser(userId); - result.setLoginName(user.getLoginname()).setUserName(user.getUsername()); - // 新增员工按参考同步方法携带预订卡及姓名;状态查询仍使用原有参数。 - NutMap requestBody = buildEmployeeRequest(user.getLoginname()) - .addv("bookId", HuaZhuConstant.CARD_ID) - .addv("userName", user.getUsername()); - request("/userInfo/addUser", requestBody); - result.setSuccess(true).setMessage("同步成功"); - } catch (Exception e) { - result.setSuccess(false).setMessage("同步失败:" + safeMessage(e)); + log.info("[华住同步][步骤=构造勾选人员查询] 开始,去重后通过IN一次查询"); + Sql sql = Sqls.create("SELECT u.id, u.loginname, u.username FROM sys_user u " + + "WHERE u.delFlag = 0 AND u.id IN (@userIds) AND " + PENDING_CONDITION + " ORDER BY u.id"); + sql.setParam("userIds", new ArrayList<>(new LinkedHashSet<>(userIds))); + return createTask(sql, "SELECTED"); + } + + /** 在提交事务中保存批次和人员快照;唯一活动槽位防止并发重复提交。 */ + @Aop(TransAop.READ_COMMITTED) + private HuaZhuSyncTask createTask(Sql sql, String source) { + long started = System.nanoTime(); + String step = "检查同步就绪状态"; + String taskId = "尚未分配"; + log.infof("[华住同步][步骤=%s] 开始,来源=%s", step, source); + if (!syncReady) { + log.warnf("[华住同步][步骤=%s] 拒绝提交,后台未就绪", step); + throw new BaseException(syncUnavailableMessage); + } + // 点击按钮时先创建或复用后台任务,避免触发器不存在(NONE)导致无法提交。 + // 调度器与本请求独立;它只会读到事务已提交的批次,不在这里直接推送。 + ensureSyncJob(); + NutMap background = backgroundStatus(null); + if (!background.getBoolean("available")) { + throw new BaseException(background.getString("message")); + } + try { + step = "检查活动批次"; + log.infof("[华住同步][步骤=%s] 开始,来源=%s", step, source); + if (dao.count(HuaZhuSyncTask.class, Cnd.where("activeSlot", "=", ACTIVE_SLOT)) > 0) { + throw new BaseException("已有推送任务正在处理,请点击更新结果查看进度"); } - results.add(result); + // 先占用活动槽位再读取人员,避免并发提交持有过期的待推送人员快照。 + HuaZhuSyncTask task = new HuaZhuSyncTask(); + task.setId(newId()); + taskId = task.getId(); + task.setActiveSlot(ACTIVE_SLOT); + task.setSource(source); + task.setStatus("RUNNING"); + task.setTotal(0); + task.setSubmittedAt(System.currentTimeMillis()); + task.setLeaseUntil(0L); + step = "保存批次及占用活动槽位"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,来源=%s", step, taskId, source); + dao.insert(task); + log.infof("[华住同步][步骤=%s] SQL执行完成,批次=%s,等待外层事务提交", step, taskId); + sql.setEntity(dao.getEntity(Sys_user.class)); + sql.setCallback(Sqls.callback.entities()); + step = "查询本批次人员"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s", step, taskId); + dao.execute(sql); + List users = sql.getList(Sys_user.class); + log.infof("[华住同步][步骤=%s] 完成,批次=%s,人数=%d", step, taskId, users.size()); + if (users.isEmpty()) { + // 外层服务事务回滚刚占用的槽位;不会留下空的活动批次。 + throw new BaseException("没有需要推送的人员;已推送、已排队及待核实人员不会重复提交"); + } + task.setTotal(users.size()); + step = "更新批次人数"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,人数=%d", step, taskId, users.size()); + dao.update(task, "total"); + log.infof("[华住同步][步骤=%s] SQL执行完成,批次=%s,等待外层事务提交", step, taskId); + step = "构建人员快照"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,人数=%d", step, taskId, users.size()); + List records = new ArrayList<>(); + for (Sys_user user : users) { + HuaZhuSyncRecord record = new HuaZhuSyncRecord(); + record.setId(newId()); + record.setTaskId(task.getId()); + record.setUserId(user.getId()); + record.setLoginName(user.getLoginname()); + record.setUserName(user.getUsername()); + record.setSubmittedAt(task.getSubmittedAt()); + record.setStatus(StrUtil.isBlank(user.getLoginname()) ? "FAILED" : "PENDING"); + record.setMessage(StrUtil.isBlank(user.getLoginname()) ? "用户缺少工号,无法同步华住" : "等待后台推送"); + if ("FAILED".equals(record.getStatus())) { + record.setFinishedAt(task.getSubmittedAt()); + } + records.add(record); + } + log.infof("[华住同步][步骤=%s] 完成,批次=%s,人数=%d", step, taskId, records.size()); + step = "批量保存待推送记录"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,人数=%d", step, taskId, records.size()); + dao.insert(records); + // 此处仍在 controller 外层事务中,只报告 SQL 返回,不能提前宣称批次已提交。 + log.infof("[华住同步][步骤=%s] SQL执行完成,批次=%s,等待外层事务提交,耗时=%dms", step, taskId, (System.nanoTime() - started) / 1_000_000L); + return task; + } catch (BaseException e) { + log.warnf("[华住同步][步骤=%s] 提交被拒绝,批次=%s,异常类型=%s,耗时=%dms", step, taskId, e.getClass().getName(), (System.nanoTime() - started) / 1_000_000L); + throw e; + } catch (Exception e) { + log.errorf("[华住同步][步骤=%s] 创建批次失败,批次=%s,异常类型=%s,耗时=%dms", step, taskId, e.getClass().getName(), (System.nanoTime() - started) / 1_000_000L); + log.error("创建华住推送批次失败", e); + // 异常必须穿过 controller 的事务拦截器,再由项目 WkFailProcessor 返回中文 msg。 + throw new BaseException("创建推送任务失败,请更新结果确认是否已有任务,稍后重试"); } - return results; } /** * 无需入参,按规则内单位主键读取名称,避免向操作人员展示单位编码。 + * * @return 中文同步规则;任一单位不存在或没有名称时抛出异常,禁止继续推送 */ @Override public String getSyncRuleDescription() { + log.info("[华住同步][步骤=读取同步规则单位] 开始"); List unitNames = new ArrayList<>(); for (String unitId : RULE_UNIT_IDS) { - Sys_unit unit = dao.fetch(Sys_unit.class, Cnd.where("id", "=", unitId).and("delFlag", "=", false)); + log.infof("[华住同步][步骤=读取单个规则单位] 开始,单位主键=%s", unitId); + Sys_unit unit = dao.fetch(Sys_unit.class, Cnd.where("id", "=", unitId).and("unitLevel", "=", "2").and("delFlag", "=", false)); if (unit == null || StrUtil.isBlank(unit.getName())) { + log.warnf("[华住同步][步骤=读取单个规则单位] 失败,单位主键=%s,单位不存在或名称为空", unitId); throw new IllegalStateException("无法获取同步规则中的单位名称,请检查单位信息"); } + log.infof("[华住同步][步骤=读取单个规则单位] 完成,单位主键=%s", unitId); unitNames.add("【" + unit.getName() + "】"); } + log.infof("[华住同步][步骤=读取同步规则单位] 完成,单位数量=%d", unitNames.size()); return "仅同步在岗人员,其中" + String.join("和", unitNames) - + "仅同步非自聘人员,其他单位不限制人员类型。"; + + "仅同步非自聘人员,其他单位不限制人员类型。"; + } + + /** 无请求参数;按在岗、单位和非自聘规则创建批次,搜索及分页不影响范围。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public HuaZhuSyncTask syncRuleUsers() { + log.info("[华住同步][步骤=接收规则同步] 开始,检查后台就绪状态"); + // 初始化失败时直接返回模块原因,不再读取规则单位或尝试创建批次。 + if (!syncReady) { + log.warn("[华住同步][步骤=接收规则同步] 拒绝提交,后台未就绪"); + throw new BaseException(syncUnavailableMessage); + } + try { + getSyncRuleDescription(); + } catch (IllegalStateException e) { + log.warnf("[华住同步][步骤=读取同步规则单位] 拒绝提交,异常类型=%s", e.getClass().getName()); + throw new BaseException(e.getMessage()); + } catch (Exception e) { + log.errorf("[华住同步][步骤=读取同步规则单位] 失败,异常类型=%s", e.getClass().getName()); + log.error("读取华住同步规则失败", e); + throw new BaseException("读取同步规则失败,请稍后重试"); + } + // 保持原规则的空值语义;通过 IN 参数集合支持一个或多个单位。 + log.info("[华住同步][步骤=构造规则人员查询] 开始,按在岗和人员类型规则筛选"); + Sql sql = Sqls.create("SELECT u.id, u.loginname, u.username FROM sys_user u " + + "WHERE u.delFlag = 0 AND u.userState = @userState " + + "AND (u.unitId NOT IN (@unitIds) OR (u.unitId IN (@unitIds) AND u.personType != @personType)) " + + "AND " + PENDING_CONDITION + " ORDER BY u.id"); + sql.setParam("userState", "在岗"); + sql.setParam("unitIds", RULE_UNIT_IDS); + sql.setParam("personType", "自聘"); + return createTask(sql, "RULE"); } /** - * 无需入参,复用参考方法的在岗及单位/人员类型条件,并排除当前项目已删除人员。 - * @return 与选中同步一致的逐人结果列表;无符合人员时为空列表 + * 无参数、无返回值;由固定 Quartz Job 串行执行一个已提交批次。 + * 网络循环不加事务;所有写库通过 IOC 代理调用下面的短事务方法,确保逐人提交。 + */ + @Override + public void runPendingTask() { + lastTriggeredAt = System.currentTimeMillis(); + log.info("[华住同步][步骤=后台执行入口] 开始"); + if (!syncReady || !executing.compareAndSet(false, true)) { + log.infof("[华住同步][步骤=后台执行入口] 跳过,就绪=%s,已有执行线程=%s", syncReady, executing.get()); + return; + } + HuaZhuSyncTask task = null; + HuaZhuEmployeeService service = null; + String step = "读取活动批次"; + long started = System.nanoTime(); + try { + service = ioc.get(HuaZhuEmployeeServiceImpl.class); + log.info("[华住同步][步骤=读取活动批次] 开始"); + task = service.prepareBatch(); + if (task == null) { + log.info("[华住同步][步骤=读取活动批次] 无待执行批次"); + return; + } + executingTaskId = task.getId(); + step = "获取本批次Token"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s", step, task.getId()); + HuaZhuAuthTokenResultDTO token = huaZhuAuthService.getToken(); + String accessToken = token.getAccessToken(); + // 原缓存命中路径直接返回 DTO;在外层阻止空 Token 进入人员循环,认证方法保持原行为。 + if (StrUtil.isBlank(accessToken)) { + throw new IllegalStateException("请求华住认证接口失败,返回的Token为空"); + } + log.infof("[华住同步][步骤=%s] 完成,批次=%s,本批次循环复用该Token", step, task.getId()); + step = "读取本批次待推送名单"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s", step, task.getId()); + List list = dao.query(HuaZhuSyncRecord.class, + Cnd.where("taskId", "=", task.getId()).and("status", "=", "PENDING").asc("id")); + log.infof("[华住同步][步骤=%s] 完成,批次=%s,人数=%d", step, task.getId(), list.size()); + int index = 0; + for (HuaZhuSyncRecord row : list) { + index++; + step = "保存发送中状态"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,工号=%s,进度=%d/%d", + step, task.getId(), logValue(row.getLoginName()), index, list.size()); + if (!service.markSending(row.getId(), task.getId())) { + throw new IllegalStateException("人员状态已变化,停止本批次以避免重复发送"); + } + log.infof("[华住同步][步骤=%s] 事务已返回,记录=%s", step, row.getId()); + // 保持原人员参数和序列化;只有明确认证失败才刷新 Token 并重试当前人员一次。 + // 网络异常或回执不明确时仍标记待核实,避免重复创建员工。 + long personStarted = System.nanoTime(); + step = "构造人员推送数据"; + log.infof("[华住同步][步骤=%s] 开始,工号=%s", step, logValue(row.getLoginName())); + HashMap map = new HashMap<>(); + map.put("cardId", HuaZhuConstant.CARD_ID); + map.put("bookId", HuaZhuConstant.CARD_ID); + map.put("cardPassWord", HuaZhuConstant.CARD_PASSWORD); + map.put("userNumber", row.getLoginName()); + map.put("userName", row.getUserName()); + String body = Json.toJson(map); + String authenticationFailure = null; + try { + for (int attempt = 0; attempt < 2; attempt++) { + step = "发起人员推送请求"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,工号=%s,进度=%d/%d,尝试=%d/2", + step, task.getId(), logValue(row.getLoginName()), index, list.size(), attempt + 1); + try (HttpResponse response = HttpUtil.createPost(HuaZhuConstant.BASEURL + "/userInfo/addUser") + .header("Accept-Encoding", "gzip,delete") + .header("Authorization", "Bearer " + accessToken) + .body(body) + .execute()) { + step = "读取人员推送回执"; + log.infof("[华住同步][步骤=%s] 开始,工号=%s,HTTP=%d", step, logValue(row.getLoginName()), response.getStatus()); + String reply = response.body(); + log.infof("[华住同步][步骤=人员回执] 批次=%s,工号=%s,HTTP=%d,尝试=%d/2,回执=%s", + task.getId(), logValue(row.getLoginName()), response.getStatus(), attempt + 1, logValue(sanitizeReply(reply))); + // 华住可能以 HTTP 200 返回 code=545 和认证失败消息,不能只判断 HTTP 状态码。 + if (response.getStatus() != 401 && !isTokenFailure(reply)) { + step = "判定人员结果"; + applyReceipt(row, response.getStatus(), reply); + break; + } + row.setStatus("FAILED"); + row.setMessage(response.getStatus() == 401 ? "华住拒绝认证(HTTP 401)" : sanitizeReply(reply)); + } + // 响应关闭后清除失效缓存;新 Token 同时替换循环变量,供当前重试及后续人员使用。 + step = "刷新推送Token"; + redisService.del(TOKEN_CACHE_KEY); + if (attempt == 1) { + authenticationFailure = "重新获取Token后华住仍返回认证失败,本批次已停止"; + row.setMessage(authenticationFailure + ";" + row.getMessage()); + break; + } + log.infof("[华住同步][步骤=%s] 开始,批次=%s,工号=%s", step, task.getId(), logValue(row.getLoginName())); + HuaZhuAuthTokenResultDTO refreshedToken = huaZhuAuthService.getToken(); + if (refreshedToken == null || StrUtil.isBlank(refreshedToken.getAccessToken())) { + throw new IllegalStateException("请求华住认证接口失败,返回的Token为空"); + } + accessToken = refreshedToken.getAccessToken(); + log.infof("[华住同步][步骤=%s] 完成,当前人员重试一次,后续人员复用新Token,批次=%s,工号=%s", + step, task.getId(), logValue(row.getLoginName())); + } + } catch (Exception e) { + if ("刷新推送Token".equals(step)) { + // 上一次请求已明确被拒绝,刷新失败无需标记为发送结果不明。 + authenticationFailure = "重新获取华住Token失败,本批次已停止:" + authenticationMessage(e); + row.setStatus("FAILED"); + row.setMessage(authenticationFailure); + } else { + // 首次请求或重试发生网络异常时,不确定华住是否已接收,保留待核实且不再重发。 + row.setStatus("UNKNOWN"); + row.setMessage("推送请求异常,结果待核实,请先查询华住状态(" + e.getClass().getSimpleName() + ")"); + } + logFailure(step, task.getId(), row.getLoginName(), e); + } + step = "保存人员结果"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s,工号=%s,状态=%s", step, task.getId(), logValue(row.getLoginName()), row.getStatus()); + service.finishRecord(row); + log.infof("[华住同步][步骤=%s] 事务已返回,批次=%s,工号=%s,状态=%s,耗时=%dms", + step, task.getId(), logValue(row.getLoginName()), row.getStatus(), (System.nanoTime() - personStarted) / 1_000_000L); + if (authenticationFailure != null) { + // 先提交当前明确失败结果,再结束剩余未发送人员,防止整批继续使用被拒绝的认证。 + step = "认证失败停止批次"; + log.warnf("[华住同步][步骤=%s] 批次=%s,工号=%s,原因=%s", + step, task.getId(), logValue(row.getLoginName()), authenticationFailure); + service.failBatch(task.getId(), authenticationFailure); + return; + } + } + step = "结束批次"; + log.infof("[华住同步][步骤=%s] 开始,批次=%s", step, task.getId()); + service.completeBatch(task.getId()); + log.infof("[华住同步][步骤=结束批次] 事务已返回,批次=%s,耗时=%dms", task.getId(), (System.nanoTime() - started) / 1_000_000L); + } catch (Exception e) { + logFailure(step, task == null ? null : task.getId(), null, e); + if (task != null && service != null) { + String reason = "后台处理在【" + step + "】中止:" + ("获取本批次Token".equals(step) + ? authenticationMessage(e) : "请根据批次号查看后台日志,未发送人员可重新提交"); + try { + log.infof("[华住同步][步骤=保存批次异常] 开始,批次=%s", task.getId()); + service.failBatch(task.getId(), reason); + log.infof("[华住同步][步骤=保存批次异常] 事务已返回,批次=%s", task.getId()); + } catch (Exception saveFailure) { + // 数据库不可写时无法声称结果已保存;停用本模块,避免后续调度继续发送。 + syncReady = false; + syncUnavailableMessage = "华住推送结果保存失败,后台已停用,请检查日志和数据库后重启应用"; + logFailure("保存批次异常", task.getId(), null, saveFailure); + } + } + } finally { + executingTaskId = null; + executing.set(false); + } + } + + /** 无参数;返回活动批次或 null。只在整批循环开始恢复上次中断的发送中人员,禁止重发。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public HuaZhuSyncTask prepareBatch() { + HuaZhuSyncTask task = dao.fetch(HuaZhuSyncTask.class, Cnd.where("activeSlot", "=", ACTIVE_SLOT)); + if (task == null) { + return null; + } + log.infof("[华住同步][步骤=恢复上次中断记录] 开始,批次=%s", task.getId()); + int changed = dao.update(HuaZhuSyncRecord.class, Chain.make("status", "UNKNOWN") + .add("message", "上次推送中断,结果待核实,请先查询华住状态;系统不会自动重发") + .add("finishedAt", System.currentTimeMillis()), + Cnd.where("taskId", "=", task.getId()).and("status", "=", "SENDING")); + // 兼容旧表字段,不再依赖五分钟逐人租约;同一批次由固定 Job 串行执行。 + dao.update(HuaZhuSyncTask.class, Chain.make("leaseUntil", 0L), Cnd.where("id", "=", task.getId())); + log.infof("[华住同步][步骤=恢复上次中断记录] SQL执行完成,批次=%s,待核实人数=%d", task.getId(), changed); + return task; + } + + /** recordId 为人员记录主键,taskId 为所属批次;仅 PENDING 转 SENDING,返回是否成功占用。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public boolean markSending(String recordId, String taskId) { + return dao.update(HuaZhuSyncRecord.class, Chain.make("status", "SENDING").add("message", "正在推送到华住"), + Cnd.where("id", "=", recordId).and("taskId", "=", taskId).and("status", "=", "PENDING")) == 1; + } + + /** record 为本次人员回执;仅更新所属批次的 SENDING 记录,失败抛出以阻止继续发送。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void finishRecord(HuaZhuSyncRecord record) { + int changed = dao.update(HuaZhuSyncRecord.class, + Chain.make("status", record.getStatus()).add("message", record.getMessage()).add("finishedAt", System.currentTimeMillis()), + Cnd.where("id", "=", record.getId()).and("taskId", "=", record.getTaskId()).and("status", "=", "SENDING")); + if (changed != 1) { + throw new IllegalStateException("人员结果未保存,影响行数=" + changed); + } + } + + /** taskId 为已遍历完成批次;确认没有待发送/发送中人员后结束并释放提交入口。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void completeBatch(String taskId) { + if (dao.count(HuaZhuSyncRecord.class, Cnd.where("taskId", "=", taskId).and("status", "in", new String[]{"PENDING", "SENDING"})) > 0) { + throw new IllegalStateException("批次仍有未处理人员,不能标记完成"); + } + if (dao.update(HuaZhuSyncTask.class, Chain.make("status", "COMPLETED").add("activeSlot", null) + .add("leaseUntil", 0L).add("finishedAt", System.currentTimeMillis()), + Cnd.where("id", "=", taskId).and("activeSlot", "=", ACTIVE_SLOT)) != 1) { + throw new IllegalStateException("批次结束状态保存失败"); + } + } + + /** + * taskId 为异常批次,reason 为受控中文原因;未发送的标失败,发送中的标待核实。 + * 保留此前已保存的结果;同一事务结束批次,页面可查看原因并重新提交明确未发送人员。 */ @Override @Aop(TransAop.READ_COMMITTED) - public List syncRuleUsers() { - // 推送前再次检查单位信息,避免确认后单位被删除或名称缺失仍继续同步。 - getSyncRuleDescription(); - // 保留原 SQL 的空值语义:单位为空不入选,限定单位的人员类型为空也不入选。 - Sql sql = Sqls.create("SELECT id FROM sys_user WHERE delFlag = 0 AND userState = @userState " - + "AND (unitId NOT IN (@unitOne, @unitTwo) " - + "OR (unitId IN (@unitOne, @unitTwo) AND personType != @personType)) ORDER BY id"); - sql.setParam("userState", "在岗"); - sql.setParam("unitOne", RULE_UNIT_IDS.get(0)); - sql.setParam("unitTwo", RULE_UNIT_IDS.get(1)); - sql.setParam("personType", "自聘"); - sql.setCallback(Sqls.callback.strList()); - dao.execute(sql); - return syncUsers(sql.getList(String.class)); + public void failBatch(String taskId, String reason) { + long now = System.currentTimeMillis(); + dao.update(HuaZhuSyncRecord.class, Chain.make("status", "FAILED").add("message", reason + ";该人员未发送") + .add("finishedAt", now), Cnd.where("taskId", "=", taskId).and("status", "=", "PENDING")); + dao.update(HuaZhuSyncRecord.class, Chain.make("status", "UNKNOWN").add("message", reason + ";该人员可能已发送,请先查询华住状态") + .add("finishedAt", now), Cnd.where("taskId", "=", taskId).and("status", "=", "SENDING")); + if (dao.update(HuaZhuSyncTask.class, Chain.make("status", "FAILED").add("activeSlot", null).add("leaseUntil", 0L) + .add("finishedAt", now), Cnd.where("id", "=", taskId).and("activeSlot", "=", ACTIVE_SLOT)) != 1) { + throw new IllegalStateException("批次异常状态保存失败"); + } + } + + /** 回执解析仅用于展示和落库,不参与重试或改写原请求;HTTP 成功不等于业务成功。 */ + private void applyReceipt(HuaZhuSyncRecord record, int httpStatus, String reply) { + record.setMessage(sanitizeReply(reply)); + JSONObject result; + try { + result = JSONUtil.parseObj(reply); + } catch (Exception e) { + record.setStatus("UNKNOWN"); + record.setMessage("华住回执格式无法识别,请核实:" + sanitizeReply(reply)); + return; + } + if (httpStatus >= 200 && httpStatus < 300 && "200".equals(result.getStr("code")) && Boolean.TRUE.equals(result.get("content"))) { + record.setStatus("SUCCESS"); + } else if (httpStatus >= 200 && httpStatus < 300 && "400".equals(result.getStr("code")) && "员工编号不可重复".equals(result.getStr("message"))) { + record.setStatus("EXISTS"); + } else if ("400".equals(result.getStr("code")) || Boolean.FALSE.equals(result.get("content")) || isTokenFailure(reply)) { + record.setStatus("FAILED"); + } else { + record.setStatus("UNKNOWN"); + record.setMessage("华住返回结果需核实(HTTP " + httpStatus + "):" + sanitizeReply(reply)); + } + } + + /** 返回实际调度状态;不触发任务、不修改数据库,供提交校验及页面诊断使用。 */ + private NutMap backgroundStatus(HuaZhuSyncTask task) { + NutMap result = NutMap.NEW().addv("available", false).addv("lastTriggeredAt", lastTriggeredAt) + .addv("executingTaskId", executingTaskId); + String message = ""; + try { + Trigger trigger = scheduler.getTrigger(SYNC_TRIGGER_KEY); + Trigger.TriggerState state = scheduler.getTriggerState(SYNC_TRIGGER_KEY); + result.addv("triggerState", state.name()).addv("nextFireTime", trigger == null || trigger.getNextFireTime() == null ? null : trigger.getNextFireTime().getTime()); + if (!syncReady) { + message = syncUnavailableMessage; + } else if (scheduler.isShutdown() || !scheduler.isStarted() || scheduler.isInStandbyMode()) { + message = "后台调度器尚未运行或已暂停,请检查应用启动日志后再试"; + } else if (state == Trigger.TriggerState.NONE && task == null) { + message = "尚未创建后台推送任务,点击提交后自动创建"; + } else if (trigger == null || (state != Trigger.TriggerState.NORMAL && state != Trigger.TriggerState.BLOCKED)) { + message = "后台触发器不可用(" + state + "),请检查调度日志并重启应用"; + } else { + // NutMap.addv 对同名键会追加为集合;这里必须覆盖布尔值,否则提交校验和页面都会误判。 + result.put("available", true); + if (task != null && "RUNNING".equals(task.getStatus()) && !task.getId().equals(executingTaskId) + && System.currentTimeMillis() - task.getSubmittedAt() > 15_000L) { + message = "任务已提交,尚未进入后台推送,请检查调度线程和【调度触发】日志"; + } + } + } catch (Exception e) { + message = "读取后台调度状态失败,请检查应用日志"; + logFailure("读取调度状态", null, null, e); + } + return result.addv("message", message); + } + + /** 日志单行化,防止人员数据或第三方回执中的换行破坏批次检索。 */ + private String logValue(String value) { + return value == null ? "-" : value.replace('\r', ' ').replace('\n', ' ').replace('\t', ' '); + } + + /** 输出阶段、类型和栈帧,不输出可能携带认证内容的异常原文。 */ + private void logFailure(String step, String taskId, String loginName, Exception e) { + log.errorf("[华住同步][步骤=%s] 失败,批次=%s,工号=%s,异常类型=%s,栈=%s", step, + taskId, logValue(loginName), e.getClass().getName(), java.util.Arrays.toString(e.getStackTrace())); + } + + /** taskId 为空取最近批次;返回 task、counts 状态计数、最近 20 个 tasks 及全局 running 标记。 */ + @Override + public NutMap syncSummary(String taskId) { + List tasks = dao.query(HuaZhuSyncTask.class, Cnd.orderBy().desc("submittedAt"), dao.createPager(1, 20)); + HuaZhuSyncTask task = StrUtil.isBlank(taskId) ? (tasks.isEmpty() ? null : tasks.get(0)) + : dao.fetch(HuaZhuSyncTask.class, taskId); + NutMap counts = NutMap.NEW(); + if (task != null) { + Sql sql = Sqls.create("SELECT status, COUNT(1) AS amount FROM huazhu_sync_record WHERE taskId = @taskId GROUP BY status"); + sql.setParam("taskId", task.getId()); + sql.setCallback(Sqls.callback.maps()); + dao.execute(sql); + for (NutMap row : sql.getList(NutMap.class)) { + counts.put(row.getString("status"), row.getInt("amount")); + } + } + return NutMap.NEW().addv("task", task).addv("tasks", tasks).addv("counts", counts) + .addv("background", backgroundStatus(task)) + .addv("running", dao.count(HuaZhuSyncTask.class, Cnd.where("activeSlot", "=", ACTIVE_SLOT)) > 0); + } + + /** pageForm 提供分页、批次、状态及工号/姓名条件;返回持久化的逐人回执分页,不触发推送。 */ + @Override + public Pagination pageRecords(HuaZhuSyncPageForm pageForm) { + Cnd cnd = Cnd.NEW(); + if (StrUtil.isNotBlank(pageForm.getTaskId())) { + cnd.and("taskId", "=", pageForm.getTaskId()); + } + if (StrUtil.isNotBlank(pageForm.getStatus())) { + cnd.and("status", "=", pageForm.getStatus()); + } + String keyword = StrUtil.trim(pageForm.getSearchKeyword()); + if (StrUtil.isNotBlank(keyword)) { + cnd.and(Cnd.exps("loginName", "like", "%" + keyword + "%").or("userName", "like", "%" + keyword + "%")); + } + int count = dao.count(HuaZhuSyncRecord.class, cnd); + List records = dao.query(HuaZhuSyncRecord.class, cnd.desc("submittedAt").asc("id"), + dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, records); } @Override @@ -171,7 +913,14 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { } catch (Exception e) { throw new IllegalStateException("华住查询接口未返回有效数据", e); } - JSONObject payload = extractObject(response, "data"); + // 员工查询可能以 content/data/result 包装;业务失败先提取中文原因。 + if ("400".equals(response.getStr("code")) || Boolean.FALSE.equals(response.get("content"))) { + throw new IllegalStateException("华住查询失败:" + sanitizeReply(StrUtil.blankToDefault(response.getStr("message"), "未返回有效员工状态"))); + } + JSONObject payload = extractObject(response, "content"); + if (payload == null) { + payload = extractObject(response, "data"); + } if (payload == null) { payload = extractObject(response, "result"); } @@ -182,14 +931,14 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { return response; } String message = StrUtil.blankToDefault(response.getStr("message"), response.getStr("msg")); - throw new IllegalStateException(StrUtil.isBlank(message) ? "华住未返回该员工状态" : "华住查询失败:" + message); + throw new IllegalStateException(StrUtil.isBlank(message) ? "华住未返回该员工状态" : "华住查询失败:" + sanitizeReply(message)); } /** * 从包装响应中提取对象;部分网关会将 data/result 再序列化为 JSON 字符串。 * * @param response 华住响应对象 - * @param key 包装字段名 + * @param key 包装字段名 * @return 包装内的 JSON 对象,缺失或格式不匹配时返回 null */ private JSONObject extractObject(JSONObject response, String key) { @@ -207,55 +956,79 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { return null; } - /** - * 调用华住员工接口,并统一处理 OAuth Token 与非成功状态。 - * - * @param path 华住接口路径 - * @param requestBody 请求体 - * @return 华住响应正文 - */ + /** 查询接口允许在明确认证失效时清缓存并重试一次;其他错误保留中文业务原因。 */ private String request(String path, NutMap requestBody) { - HttpResponse response = HttpRequest.post(trimTrailingSlash(HuaZhuConstant.BASEURL) + path) - .header("Authorization", getAccessToken()) - .header("accept", "application/json") - .contentType("application/json; charset=UTF-8") - .body(JSONUtil.toJsonStr(requestBody)) - .timeout(HuaZhuConstant.TIMEOUT) - .execute(); - if (!response.isOk()) { - throw new IllegalStateException("华住接口调用失败,状态码:" + response.getStatus()); + for (int attempt = 0; attempt < 2; attempt++) { + try (HttpResponse response = HttpUtil.createPost(trimTrailingSlash(HuaZhuConstant.BASEURL) + path) + .header("Accept-Encoding", "gzip,delete") + .header("Authorization", "Bearer " + getAccessToken()) + .body(Json.toJson(requestBody)).timeout(20000).execute()) { + String body = response.body(); + if (response.getStatus() == 401 || isTokenFailure(body)) { + redisService.del(TOKEN_CACHE_KEY); + if (attempt == 0) { + continue; + } + throw new IllegalStateException("华住查询失败:获取AccessToken失败,重新获取认证后仍被华住拒绝,请核对华住端授权配置"); + } + if (!response.isOk()) { + throw new IllegalStateException("华住接口调用失败,状态码:" + response.getStatus()); + } + return body; + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("连接华住接口失败或超时,请稍后重试", e); + } } - return response.body(); + throw new IllegalStateException("华住认证失败,请稍后重试"); } - /** - * 获取华住 OAuth Token,优先使用 Redis 缓存以避免频繁请求第三方认证接口。 - * - * @return HTTP Authorization 头的 Bearer Token - */ + /** 查询接口也使用原认证服务,避免推送与查询使用两套 Token 缓存及解析方式。 */ private String getAccessToken() { - String cachedToken = redisService.get(TOKEN_CACHE_KEY); - if (StrUtil.isNotBlank(cachedToken)) { - return cachedToken; + try { + return huaZhuAuthService.getToken().getAccessToken(); + } catch (Exception e) { + throw new IllegalStateException("获取华住Token失败:" + authenticationMessage(e), e); } - String credential = Base64.getEncoder().encodeToString((HuaZhuConstant.CLIENT_ID + ":" + HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)); - HttpResponse response = HttpRequest.post(trimTrailingSlash(HuaZhuConstant.OAUTH2_URL) - + "/oauth/token?scope=ALL&grant_type=client_credentials") - .header("Authorization", "Basic " + credential) - .timeout(HuaZhuConstant.TIMEOUT) - .execute(); - if (!response.isOk()) { - throw new IllegalStateException("获取华住认证 Token 失败,状态码:" + response.getStatus()); + } + + /** 仅透出原认证方法的两种明确错误;网络、Redis 或解析异常不会将凭据带到页面。 */ + private String authenticationMessage(Exception e) { + String message = e.getMessage(); + if ("请求华住认证接口失败,返回的Token为空".equals(message) + || (message != null && message.matches("请求华住认证接口失败,状态码:[0-9]{3}"))) { + return message; } - JSONObject tokenData = JSONUtil.parseObj(response.body()); - String accessToken = tokenData.getStr("access_token"); - String tokenType = StrUtil.blankToDefault(tokenData.getStr("token_type"), "Bearer"); - if (StrUtil.isBlank(accessToken)) { - throw new IllegalStateException("华住认证接口未返回 Token"); + return "认证服务调用异常,请根据后台日志检查缓存、网络及响应格式"; + } + + /** 只识别已观察到的明确认证失败文本,不把一般业务错误当作 token 过期。 */ + private boolean isTokenFailure(String body) { + if (body == null) { + return false; } - String authorization = tokenType + " " + accessToken; - redisService.setex(TOKEN_CACHE_KEY, 60 * 58, authorization); - return authorization; + try { + // 先解析 JSON,兼容华住将中文消息编码为 Unicode 转义的响应。 + JSONObject response = JSONUtil.parseObj(body); + String message = StrUtil.blankToDefault(response.getStr("message"), response.getStr("msg")); + return message != null && message.contains("获取AccessToken失败"); + } catch (Exception ignored) { + return body.contains("获取AccessToken失败"); + } + } + + /** 回执只用于管理端核实;剔除认证凭证并限制长度,避免保存外部超大响应。 */ + private String sanitizeReply(String body) { + String text = StrUtil.blankToDefault(body, "华住未返回响应正文"); + text = text.replace(HuaZhuConstant.CLIENT_SECRET, "[已隐藏]") + .replace(HuaZhuConstant.CARD_PASSWORD, "[已隐藏]"); + text = text.replaceAll("(?i)(\\\"(?:access_token|accessToken|token|echoToken|cardPassWord)\\\"\\s*:\\s*\\\")[^\\\"]*", "$1[已隐藏]"); + return text.length() > 4000 ? text.substring(0, 4000) + "(回执过长,已截断)" : text; + } + + private String newId() { + return UUID.randomUUID().toString().replace("-", ""); } private NutMap buildEmployeeRequest(String loginName) { @@ -266,6 +1039,16 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { private Sys_user findAvailableUser(String userId) { Sys_user user = dao.fetch(Sys_user.class, Cnd.where("id", "=", userId).and("delFlag", "=", false)); + return validateAvailableUser(user); + } + + /** + * 实时查询前校验员工是否存在及工号是否完整,保留中文业务提示。 + * + * @param user 未删除的员工记录,查询未命中时传入 null + * @return 已通过存在性及工号校验的员工对象;不符合条件时抛出 IllegalArgumentException + */ + private Sys_user validateAvailableUser(Sys_user user) { if (user == null) { throw new IllegalArgumentException("用户不存在或已删除"); } @@ -279,7 +1062,4 @@ public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } - private String safeMessage(Exception e) { - return StrUtil.isBlank(e.getMessage()) ? "请检查华住配置和网络连接" : e.getMessage(); - } } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/controller/CondolenceApplyController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/controller/CondolenceApplyController.java index a14d4f3..e04f819 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/controller/CondolenceApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/controller/CondolenceApplyController.java @@ -81,10 +81,10 @@ public class CondolenceApplyController { } /** - * 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员。 + * 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员或校工会管理员。 * * @param endDate 截止日期,格式为 yyyy-MM-dd - * @return 保存成功结果 + * @return Result,保存成功时返回成功状态和“设置成功”提示 */ @At @ApiOperation("保存慰问申报截止日期") diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/CondolenceService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/CondolenceService.java index 683149c..bd4bd3b 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/CondolenceService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/CondolenceService.java @@ -22,7 +22,7 @@ public interface CondolenceService extends BaseService { String getApplyEndDate(); /** - * 保存慰问申报截止日期,仅允许系统管理员操作。 + * 保存慰问申报截止日期,仅允许系统管理员或校工会管理员操作。 * * @param endDate 截止日期,格式为 yyyy-MM-dd */ diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/impl/CondolenceServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/impl/CondolenceServiceImpl.java index 579a947..b8ae7d3 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/impl/CondolenceServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/condolence/service/impl/CondolenceServiceImpl.java @@ -90,15 +90,15 @@ public class CondolenceServiceImpl extends BaseServiceImpl implement } /** - * 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,且仅 SYSADMIN 可以修改。 + * 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,仅系统管理员或校工会管理员可以修改。 * * @param endDate 截止日期,格式为 yyyy-MM-dd */ @Override @Aop(TransAop.READ_COMMITTED) public void saveApplyEndDate(String endDate) { - if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) { - throw new BaseException("仅系统管理员可以设置慰问申报时间"); + if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { + throw new BaseException("仅系统管理员或校工会管理员可以设置慰问申报时间"); } parseApplyEndDate(endDate); Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY); diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/controller/process/DifficultSubsidyApplyController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/controller/process/DifficultSubsidyApplyController.java index d0fe4eb..4aee378 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/controller/process/DifficultSubsidyApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/controller/process/DifficultSubsidyApplyController.java @@ -1,11 +1,14 @@ package com.budwk.app.zhgh.staffbenefit.difficultSubsidy.controller.process; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import com.budwk.app.base.result.Result; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.models.DifficultSubsidyApply; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.service.DifficultSubsidyService; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; @@ -36,9 +39,32 @@ public class DifficultSubsidyApplyController { public void form() { } + /** + * 无请求参数;返回 Result,data 为 yyyy-MM-dd 截止日期,空字符串表示未设置。 + * 审核修改复用本表单,因此允许申请或校工会审核权限读取。 + */ + @At + @SaCheckPermission(value = {"difficultSubsidy.apply", "difficultSubsidy.schoolAudit"}, mode = SaMode.OR) + public Result getApplyEndDate() { + return Result.success().addData(difficultSubsidyService.getApplyEndDate()); + } + + /** + * @param endDate yyyy-MM-dd 格式的申报截止日期;允许设置的角色由 service 统一校验 + * @return Result 成功状态,不附加业务数据 + */ + @At + @SaCheckPermission("difficultSubsidy.apply") + @Aop(TransAop.READ_COMMITTED) + public Result saveApplyEndDate(@Param("endDate") String endDate) { + difficultSubsidyService.saveApplyEndDate(endDate); + return Result.success(); + } + @At @Ok("json") @SaCheckPermission("difficultSubsidy.apply") + @Aop(TransAop.READ_COMMITTED) public Result save(@Param("data") DifficultSubsidyApply apply) { return Result.success(difficultSubsidyService.saveDraft(apply)); } @@ -46,6 +72,7 @@ public class DifficultSubsidyApplyController { @At @Ok("json") @SaCheckPermission("difficultSubsidy.apply") + @Aop(TransAop.READ_COMMITTED) public Result submit(@Param("data") DifficultSubsidyApply apply) { return Result.success(difficultSubsidyService.submit(apply)); } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/DifficultSubsidyService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/DifficultSubsidyService.java index e6b1775..abd4355 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/DifficultSubsidyService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/DifficultSubsidyService.java @@ -15,6 +15,23 @@ import java.util.List; */ public interface DifficultSubsidyService extends BaseService { + /** + * @return 系统参数 DifficultSubsidyApplyEndDate 的 yyyy-MM-dd 日期,未配置时返回空字符串 + */ + String getApplyEndDate(); + + /** + * 仅系统管理员、校工会管理员可设置特困补助申报截止日期。 + * @param endDate yyyy-MM-dd 格式的截止日期,当天起停止新增申报 + */ + void saveApplyEndDate(String endDate); + + /** + * 校验新申请是否在申报期内,数据库中已存在的申请可继续修改和重新提交。 + * @param id 申请记录ID;空值或数据库中不存在的ID均按新申请校验 + */ + void checkApplyDeadline(String id); + DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply); DifficultSubsidyApply submit(DifficultSubsidyApply apply); diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/impl/DifficultSubsidyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/impl/DifficultSubsidyServiceImpl.java index 682e7ed..f84b29d 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/impl/DifficultSubsidyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/difficultSubsidy/service/impl/DifficultSubsidyServiceImpl.java @@ -13,6 +13,7 @@ import com.budwk.app.base.utils.SysOfficeTemplateUtil; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.entity.ProcessDefine; @@ -26,6 +27,8 @@ import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.sys.services.SysFileService; +import com.budwk.app.sys.services.SysConfigService; +import com.budwk.app.sys.models.Sys_config; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.constants.DifficultSubsidyState; @@ -56,6 +59,8 @@ import org.nutz.lang.util.NutMap; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -78,6 +83,12 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl listRecipients(String keyword) { Sql sql = Sqls.create("SELECT id, loginname, username, birthday, mobile, sex, " + "unitName, unionName, unionId, unitId FROM vw_user $condition"); Cnd cnd = Cnd.NEW(); - - // 校工会管理员可选择全校人员,分工会管理员仅可选择本工会人员,普通用户仅可选择本人。 - if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { - if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), - RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) { - cnd.and("unionId", "=", SecurityUtil.getUnionId()); - } else { - cnd.and("id", "=", SecurityUtil.getUserId()); - } - } + appendRecipientScope(cnd); if (Strings.isNotBlank(keyword)) { SqlExpressionGroup keywordGroup = new SqlExpressionGroup(); keywordGroup.orLike("username", keyword); @@ -725,23 +808,58 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl { + if (response && typeof response === "object" && response.code !== undefined && response.code !== 0) { + ELEMENT.Message.error(response.msg || "请求处理失败,请稍后重试"); + jqXHR.platformErrorShown = true; } }, - error: function (v1) { - this.$message.error(v1.status + " " + v1.statusText) + error: (xhr) => { + const message = xhr.responseJSON && xhr.responseJSON.msg; + ELEMENT.Message.error(message || (xhr.status === 0 ? "网络连接失败或请求超时,请稍后重试" : "请求失败,状态码:" + xhr.status)); + xhr.platformErrorShown = true; }, - complete: function (XMLHttpRequest, textStatus) { - var sessionstatus = XMLHttpRequest.getResponseHeader("loginStatus") //通过XMLHttpRequest取得响应头,sessionstatus, - if (sessionstatus == "accessDenied") { - this.$message({ - message: "\u767b\u5f55\u5931\u6548\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u91cd\u65b0\u767b\u5f55", - type: "error" - }) - } - if (sessionstatus == "unauthorized") { - this.$message({ - message: "\u6ca1\u6709\u6743\u9650", - type: "error" - }) + complete: (xhr) => { + const sessionStatus = xhr.getResponseHeader("loginStatus"); + if (!xhr.platformErrorShown && sessionStatus === "accessDenied") { + ELEMENT.Message.error("登录失效,请刷新页面重新登录"); + } else if (!xhr.platformErrorShown && sessionStatus === "unauthorized") { + ELEMENT.Message.error("没有权限"); } } }) diff --git a/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js b/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js index f0796c1..4c1c5c4 100644 --- a/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js +++ b/src/main/resources/static/assets/platform/js/mixin/initTableMixins.js @@ -69,13 +69,17 @@ const initTableMixins = { }, pageData(data = null) { const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData" - this.tableLoading = true - this.$axios.post(address, data ? data : this.pageForm).then((res) => { - this.tableLoading = false + this.$set(this, "tableLoading", true) + // 业务错误会由公共拦截器 reject,同样需要 finally 解除列表加载状态。 + return this.$axios.post(address, data ? data : this.pageForm).then((res) => { if (res.code === 0) { - this.tableData = res.data.list - this.pageForm.totalCount = res.data.totalCount + this.$set(this, "tableData", res.data.list) + this.$set(this.pageForm, "totalCount", res.data.totalCount) } + }).catch(() => { + // 中文错误已由统一响应拦截器提示,避免页面重复弹出。 + }).finally(() => { + this.$set(this, "tableLoading", false) }) }, notifySuccess(msg) { diff --git a/src/main/resources/static/components/plugins/sysUpload/index.vue b/src/main/resources/static/components/plugins/sysUpload/index.vue index b23d1f1..84c68e2 100644 --- a/src/main/resources/static/components/plugins/sysUpload/index.vue +++ b/src/main/resources/static/components/plugins/sysUpload/index.vue @@ -175,6 +175,12 @@ module.exports = { default: "", required: false }, + // 默认仅由文件选择器按 accept 过滤;启用后上传前再次校验扩展名,accept 需传 .pdf,.jpg 等白名单。 + validate_accept: { + type: Boolean, + default: false, + required: false + }, // 是否是完整的结果(就是文件上传返回什么,该组件返回什么,uploadResultCategory必须为array) complete_result: { type: Boolean, @@ -316,7 +322,7 @@ module.exports = { } }, - // 这是兜底逻辑,保准只让这个image类型上传图片 + // 保留原有大小限制;业务显式开启 validate_accept 时,额外按扩展名拦截不符合 accept 的文件。 beforeUpload(file) { // 需要兼容部分业务不限制附件大小,关闭后仅跳过大小校验,不影响数量和格式控制。 @@ -324,35 +330,17 @@ module.exports = { this.$message.error("文件过大,请上传小于" + (this.upload_size / 1024 / 1024).toFixed(0) + "M的文件!") return false } - - + if (this.validate_accept && this.fileAccept) { + const acceptTypes = this.fileAccept.split(",").map((type) => type.trim().toLowerCase()) + const fileName = (file.name || "").toLowerCase() + const dotIndex = fileName.lastIndexOf(".") + // 文件名可能没有扩展名或被伪装为“申请表.pdf.docx”,始终只校验最后一个扩展名。 + if (dotIndex < 0 || !acceptTypes.includes(fileName.substring(dotIndex))) { + this.$message.error("文件只能是 " + this.fileAccept + " 格式!") + return false + } + } return true - - // if (!this.fileAccept || typeof this.fileAccept !== "string") { - // return true - // } - // - // // 去除前后空格并转换为数组 - // const acceptTypes = this.fileAccept - // .trim() - // .split(",") - // .map((type) => type.trim().toUpperCase()) - // - // let fileType - // const dotIndex = file.name.lastIndexOf(".") - // if (dotIndex !== -1) { - // fileType = file.name.substring(dotIndex).toUpperCase() - // } else { - // this.$message.error("文件必须包含有效的扩展名!") - // return false - // } - // - // const valid = acceptTypes.includes(fileType) - // if (valid) { - // return true - // } - // this.$message.error(`文件只能是 ${this.fileAccept} 格式!`) - // return false }, // 预览图片 diff --git a/src/main/resources/views/platform/zhgh/activity/trainSignUp/apply/applyForm.js b/src/main/resources/views/platform/zhgh/activity/trainSignUp/apply/applyForm.js index 54d868c..fed0a3e 100644 --- a/src/main/resources/views/platform/zhgh/activity/trainSignUp/apply/applyForm.js +++ b/src/main/resources/views/platform/zhgh/activity/trainSignUp/apply/applyForm.js @@ -33,11 +33,16 @@ const applyForm = { - + + - --> + @@ -103,8 +108,8 @@ const applyForm = { - 取 消 - 提 交 + 取 消 + 提 交 @@ -114,60 +119,81 @@ const applyForm = { data() { return { signDialog: false, + formLoading: false, formData: {}, + mobileColumn: null, courseRow: {}, courseTimeSelectList: [], } }, methods: { - async getCourseTimeSelectList(o) { - const resp = await $.post('/platform/trainSignUp/apply/getCourseTimeSelectList',{ courseId: o.id }) - if (resp.code === 0) { - this.courseTimeSelectList = resp.data - } else { - this.$message.warning('获取时段信息失败,请联系管理员') - } - }, - async onOpen(row, course) { - this.initData(row, course) - if (row.courseIsLimitApply) { - await this.getCourseTimeSelectList(row) - } - this.courseRow = row - this.signDialog = true - this.$nextTick(() => { - this.$refs.form.clearValidate() + /** + * o.id 为当前课程 ID;接口返回 Result,data 为含 value(时段 ID)、text(时段描述)的选项数组。 + */ + getCourseTimeSelectList(o) { + this.formLoading = true + return $.post('/platform/trainSignUp/apply/getCourseTimeSelectList', { courseId: o.id }).then((resp) => { + if (resp.code === 0) { + this.courseTimeSelectList = resp.data + } else { + this.$message.warning('获取时段信息失败,请联系管理员') + } + }).always(() => { + this.formLoading = false }) }, - async validateSignUp() { - // 获取家属人数 + // row 为课程记录,course 为课程类型及其动态字段配置;限额课程返回时段加载完成后的 Promise。 + onOpen(row, course) { + this.initData(row, course) + const openForm = () => { + this.courseRow = row + this.signDialog = true + this.$nextTick(() => { + this.$refs.form.clearValidate() + }) + } + // 限额课程先取得可报名时段,再展示表单,保持原有打开顺序。 + if (row.courseIsLimitApply) return this.getCourseTimeSelectList(row).then(openForm) + openForm() + }, + /** + * 传入 courseId(课程 ID)、currentFamilyNumber(动态表单填写的家属总人数),返回报名资格是否通过的 Promise。 + */ + validateSignUp() { + // 家属人数继续取动态字段 xdqsrs,空值或非数值按 0 参与报名人数校验。 let familyCount = this.formData.trainMobileSignColumnList.filter(o => o.columnCode === 'xdqsrs').reduce((sum, item) => { return sum + (Number(item.columnValue) || 0) }, 0) - const res = await this.$axios.post("/platform/trainSignUp/apply/validateSignUp", { + return this.$axios.post("/platform/trainSignUp/apply/validateSignUp", { courseId: this.formData.courseId, currentFamilyNumber: familyCount - }) - return res.code === 0 + }).then((res) => res.code === 0) }, - async validateCourseTime() { - const res = await this.$axios.post('/platform/trainSignUp/apply/validateSourceSignUp', { + /** + * 传入 activityCourseId(所选报名时段 ID)、courseId(课程 ID),返回时段名额校验是否通过的 Promise。 + */ + validateCourseTime() { + return this.$axios.post('/platform/trainSignUp/apply/validateSourceSignUp', { activityCourseId: this.formData.activityCourseId, courseId: this.courseRow.id - }) - return res.code === 0 + }).then((res) => res.code === 0) }, - async onSubmit() { - if(!await this.validateSignUp()) return - - this.$refs["form"].validate(async (valid) => { - if (valid) { - if(this.courseRow.courseIsLimitApply && !await this.validateCourseTime()) return - this.$confirm("您确定要提交吗?", "提示", { + // 返回完整校验和报名提交链路的 Promise;正在处理时直接返回,提交按钮同步显示加载状态。 + onSubmit() { + // 锁定资格校验至提交完成的整个链路,避免重复点击产生多次报名请求。 + if (this.formLoading) return + this.formLoading = true + return this.validateSignUp().then((valid) => { + if (!valid) return + return this.$refs.form.validate().then(() => { + return this.courseRow.courseIsLimitApply ? this.validateCourseTime() : true + }).then((validTime) => { + if (!validTime) return + return this.$confirm("您确定要提交吗?", "提示", { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" - }).then(async () => { + }).then(() => { const mobileColumnsValue = this.formData.trainMobileSignColumnList.map((v) => { return { columnName: v.columnName, @@ -176,28 +202,51 @@ const applyForm = { columnFormType: v.columnFormType } }) - this.formData.mobileColumnsValue = JSON.stringify(mobileColumnsValue) - const resp = await this.$axios.post("/platform/trainSignUp/apply/doSignUp", this.formData) - if (resp.code === 0) { - this.signDialog = false - this.$message.success(resp.msg) - this.$emit('refresh') - } else { - this.$message.warning(resp.msg) + // 已配置 mobile 动态字段时同步最新手机号,兼容按动态字段读取数据的原有导出。 + if (this.mobileColumn) { + mobileColumnsValue.push({ + columnName: this.mobileColumn.columnName, + columnValue: this.formData.mobile, + columnCode: this.mobileColumn.columnCode, + columnFormType: this.mobileColumn.columnFormType + }) } + this.$set(this.formData, 'mobileColumnsValue', JSON.stringify(mobileColumnsValue)) + // activityId、courseId、activityCourseId 分别标识活动、课程和所选时段;mobile 为本次报名联系方式。 + // mobileColumnsValue 为含字段名、编码、控件类型及值的 JSON 数组,其他基础信息保持原表单内容。 + // 接口返回 Result,code 为 0 表示报名成功,msg 用于结果提示。 + return this.$axios.post("/platform/trainSignUp/apply/doSignUp", this.formData).then((resp) => { + if (resp.code === 0) { + this.signDialog = false + this.$message.success(resp.msg) + this.$emit('refresh') + } else { + this.$message.warning(resp.msg) + } + }) }) - } + }) + }).catch(() => { + // 取消确认或表单校验不通过时保留当前填写内容;网络错误由公共请求拦截器提示。 + }).finally(() => { + this.formLoading = false }) }, + // row 提供活动、课程 ID,course 提供动态字段;仅准备本次报名表单及手机号元数据。 initData(row, course) { this.$set(this.formData, 'username', this.$store.state.user.username) this.$set(this.formData, 'loginname', this.$store.state.user.loginname) this.$set(this.formData, 'unionName', this.$store.state.user.union.name) this.$set(this.formData, 'unitName', this.$store.state.user.unit.name) this.$set(this.formData, 'sex', this.$store.state.user.sex) + // 默认取登录用户手机号,可编辑且只随本次报名提交,不修改个人资料。 + this.$set(this.formData, 'mobile', this.$store.state.user.mobile || '') this.$set(this.formData, 'activityId', row.activityId) this.$set(this.formData, 'courseId', row.id) - this.$set(this.formData, 'trainMobileSignColumnList', course.trainMobileSignColumnList) + // 复制配置以免修改课程类型原数据;手机号统一必填,不受动态 isRequired 影响,保留 mobile 元数据兼容同步提交。 + const columns = clone(course.trainMobileSignColumnList) + this.mobileColumn = columns.find((item) => item.columnCode === 'mobile') || null + this.$set(this.formData, 'trainMobileSignColumnList', columns.filter((item) => item.columnCode !== 'mobile')) this.formData.trainMobileSignColumnList.forEach((item) => { item.columnValue = this.$store.state.user[item.columnCode] || "" diff --git a/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/basicForm.js b/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/basicForm.js index f6d1abf..1060212 100644 --- a/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/basicForm.js +++ b/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/basicForm.js @@ -96,8 +96,8 @@ const basicForm = { - - + + @@ -221,8 +221,8 @@ const basicForm = { @@ -324,7 +324,6 @@ const basicForm = { `, - dicts: ["USER_CAMPUS", "TRAIN_SIGNUP_TYPE"], data() { return { signUpDialog: false, @@ -356,6 +355,8 @@ const basicForm = { } }, campusList: [], + dictOptionsLoading: { USER_CAMPUS: false, TRAIN_SIGNUP_TYPE: false }, + dictOptionsRequests: { USER_CAMPUS: null, TRAIN_SIGNUP_TYPE: null }, courseTypeList: [], undertakeOptions: [], } @@ -523,8 +524,56 @@ const basicForm = { }) } }, + /** + * 查询本表单的下拉字典。code 仅传 USER_CAMPUS(校区)或 TRAIN_SIGNUP_TYPE(活动类型)。 + * 接口返回 {code: 0, data: [{name, code}]};name 为显示名称,校区保存 name,活动类型保存 code。 + * 返回 Promise,失败时返回空数组;共用进行中的请求,失败或无选项时允许重新展开下拉框重试。 + */ + loadDictOptions(code) { + const options = { + USER_CAMPUS: { field: "campusList", label: "校区" }, + TRAIN_SIGNUP_TYPE: { field: "trainTypeList", label: "活动类型" } + }[code] + if (!options) { + return Promise.resolve([]) + } + if (this.dictOptionsRequests[code]) { + return this.dictOptionsRequests[code] + } + if (this[options.field].length > 0) { + return Promise.resolve(this[options.field]) + } + this.$set(this.dictOptionsLoading, code, true) + const request = this.$axios.post("/open/common/dictOptions", { code }) + .then((res) => { + if (!res || res.code !== 0 || !Array.isArray(res.data)) { + this.$message.warning(options.label + "选项加载失败,请重新展开下拉框重试") + return [] + } + this.$set(this, options.field, res.data) + if (res.data.length === 0) { + this.$message.warning(options.label + "暂无可用选项,请检查字典配置") + } + // 重试成功后按当前表单值更新业务名称,避免早于字典返回的回显停留在默认名称。 + if (code === "TRAIN_SIGNUP_TYPE") { + this.typeChange(this.formData.trainType) + } + return res.data + }) + .catch(() => { + // 网络及接口异常由全局 axios 拦截器提示;保留原表单值,允许下次展开时重试。 + return [] + }) + .finally(() => { + this.$set(this.dictOptionsLoading, code, false) + this.$set(this.dictOptionsRequests, code, null) + }) + this.$set(this.dictOptionsRequests, code, request) + return request + }, + // val 为活动类型字典编码;按已查询的选项更新名称,未匹配时沿用原有默认名称。 typeChange(val) { - const type = this.dict.type.TRAIN_SIGNUP_TYPE.find(o => o.code === val) + const type = this.trainTypeList.find(o => o.code === val) this.trainType = type?.name || "培训班" }, changeActivity(val) { @@ -669,13 +718,25 @@ const basicForm = { } } }, - async initData(row, copyTemplateMode = false) { + // row 为待编辑活动(新建时不传),copyTemplateMode 标识模板复制;返回完成字典及表单回显的 Promise。 + initData(row, copyTemplateMode = false) { this.copyTemplateMode = copyTemplateMode - this.activityGroupList = await this.getActivityGroup() - this.historicalActList = await this.getHistoricalActList() - this.courseTypeList = await this.getAllType() - await this.selectUnitAndClub() - await this.init(row) + // 两类字典查询完成后再初始化表单,避免活动类型回显与字典加载发生竞争。 + return Promise.all([this.loadDictOptions("USER_CAMPUS"), this.loadDictOptions("TRAIN_SIGNUP_TYPE")]) + .then(() => this.getActivityGroup()) + .then((data) => { + this.activityGroupList = data + return this.getHistoricalActList() + }) + .then((data) => { + this.historicalActList = data + return this.getAllType() + }) + .then((data) => { + this.courseTypeList = data + return this.selectUnitAndClub() + }) + .then(() => this.init(row)) }, async selectUnitAndClub() { const resp = await this.$axios.post("/platform/trainSignUp/manage/selectUnitAndClub") diff --git a/src/main/resources/views/platform/zhgh/huazhu/index.html b/src/main/resources/views/platform/zhgh/huazhu/index.html index eb1a6e7..839d366 100644 --- a/src/main/resources/views/platform/zhgh/huazhu/index.html +++ b/src/main/resources/views/platform/zhgh/huazhu/index.html @@ -1,6 +1,79 @@ +