From c903e0d9348a000377d8774183ee9be791f505ec Mon Sep 17 00:00:00 2001 From: zhangrui Date: Mon, 6 Jul 2026 14:34:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=97=E5=A4=A7=E4=B8=89=E5=8D=81=E5=B9=B4?= =?UTF-8?q?=E6=95=99=E8=81=8C=E5=B7=A5=E7=96=97=E4=BC=91=E5=85=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SysDataUserPullController.java | 11 + .../sys/services/SysDataUserPullService.java | 7 + .../impl/SysDataUserPullServiceImpl.java | 134 +++++++++ .../budwk/app/sys/utils/SysFileMinIoUtil.java | 3 +- .../ThirtyTeachTourLedgerController.java | 276 +++++++++++++++--- ...ch_tour_add_current_period_photo_files.sql | 6 + .../ThirtyTeachTour/ledger/index.html | 1 + 7 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 src/main/resources/db/thirtyTeachTour/thirty_teach_tour_add_current_period_photo_files.sql diff --git a/src/main/java/com/budwk/app/sys/controller/SysDataUserPullController.java b/src/main/java/com/budwk/app/sys/controller/SysDataUserPullController.java index fa206eb9..95682a9f 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysDataUserPullController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysDataUserPullController.java @@ -69,6 +69,17 @@ public class SysDataUserPullController { return Result.success(); } + @At + @SaCheckPermission("sys.data.user.pull") + @ApiOperation("同步教职工手机号") + public Result syncTeacherMobile() { + try { + return Result.success(sysUserPullService.syncTeacherMobile()); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } + @At @SaCheckPermission("sys.data.user.pull") @ApiOperation("删除用户数据") diff --git a/src/main/java/com/budwk/app/sys/services/SysDataUserPullService.java b/src/main/java/com/budwk/app/sys/services/SysDataUserPullService.java index d12dec1c..8afec62d 100644 --- a/src/main/java/com/budwk/app/sys/services/SysDataUserPullService.java +++ b/src/main/java/com/budwk/app/sys/services/SysDataUserPullService.java @@ -38,4 +38,11 @@ public interface SysDataUserPullService extends BaseService { * @return */ Map pullFinance(); + + /** + * 从数据中心分页拉取教职工手机号,并按工号同步到 sys_user.mobile。 + * + * @return 同步过程统计信息 + */ + NutMap syncTeacherMobile(); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java index efdb216c..2d4365f2 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java @@ -28,6 +28,7 @@ import com.budwk.app.sys.services.SysDictService; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.async.Async; import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; @@ -47,6 +48,10 @@ import java.util.stream.Collectors; @Slf4j public class SysDataUserPullServiceImpl extends BaseServiceImpl implements SysDataUserPullService { + private static final String TEACHER_MOBILE_CONFIG_KEY = "teacherMobile"; + private static final int TEACHER_MOBILE_PAGE_SIZE = 1000; + private static final int TEACHER_MOBILE_DB_BATCH_SIZE = 500; + @Inject private SysDictService sysDictService; @Inject @@ -400,6 +405,135 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl return listMap(sql); } + /** + * 从数据中心分页同步教职工手机号,并按工号更新 sys_user.mobile。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public NutMap syncTeacherMobile() { + DataCenterProperties.Credential credential = dataCenterProperties.credential(TEACHER_MOBILE_CONFIG_KEY); + String url = credential.getUrl(); + String token = credential.getToken(); + log.info("教职工手机号同步开始:url={}, tokenReady={}", url, StrUtil.isNotBlank(token)); + + List rawDataList = new ArrayList<>(); + int pageNum = 1; + int pageSize = TEACHER_MOBILE_PAGE_SIZE; + int total = 0; + int pages = 1; + do { + Map reqBody = buildTeacherMobileRequestBody(pageNum, pageSize); + log.info("教职工手机号接口请求:pageNum={}, pageSize={}, body={}", pageNum, pageSize, reqBody); + + HttpRequest httpRequest = HttpUtil.createPost(url); + httpRequest.header(Header.CONTENT_TYPE, "application/json"); + httpRequest.header("X-H3C-TOKEN", token); + httpRequest.body(JSONUtil.toJsonStr(reqBody)); + + String resBody = httpRequest.execute().body(); + log.info("教职工手机号接口返回:pageNum={}, response={}", pageNum, resBody); + JSONObject resp = JSONUtil.parseObj(resBody); + if (!"0".equals(resp.getStr("code"))) { + throw new BaseException("获取教职工手机号失败,错误码: " + resp.getStr("code") + ";错误原因:" + resp.getStr("msg")); + } + + JSONObject data = resp.getJSONObject("data"); + if (data != null) { + total = data.getInt("total", total); + pages = data.getInt("pages", pages); + if (pages <= 0 && total > 0) { + pages = (int) Math.ceil((double) total / pageSize); + } + } + + JSONArray records = readTeacherMobileRecords(resp, data); + if (CollUtil.isNotEmpty(records)) { + rawDataList.addAll(records.stream().map(v -> (JSONObject) v).toList()); + } + log.info("教职工手机号拉取进度:pageNum={}, pages={}, total={}, pulled={}", + pageNum, pages, total, rawDataList.size()); + pageNum++; + } while (pageNum <= Math.max(pages, 1)); + + Map mobileMap = collectTeacherMobileMap(rawDataList); + NutMap updateResult = updateTeacherMobile(mobileMap); + NutMap result = NutMap.NEW() + .addv("total", total) + .addv("pages", pages) + .addv("pulledCount", rawDataList.size()) + .addv("validMobileCount", mobileMap.size()) + .addv("matchedUserCount", updateResult.getInt("matchedUserCount", 0)) + .addv("updatedUserCount", updateResult.getInt("updatedUserCount", 0)); + log.info("教职工手机号同步完成:{}", result); + return result; + } + + /** + * 组装手机号接口分页请求体,uid、gh、sjh 为空时表示全量分页拉取。 + */ + private Map buildTeacherMobileRequestBody(int pageNum, int pageSize) { + Map reqBody = new LinkedHashMap<>(); + reqBody.put("uid", null); + reqBody.put("gh", null); + reqBody.put("sjh", null); + reqBody.put("pageNum", pageNum); + reqBody.put("pageSize", pageSize); + return reqBody; + } + + /** + * 读取手机号接口明细数据,优先按文档读取顶层 records,同时兼容 data.records。 + */ + private JSONArray readTeacherMobileRecords(JSONObject resp, JSONObject data) { + JSONArray records = resp.getJSONArray("records"); + if (records == null && data != null) { + records = data.getJSONArray("records"); + } + return records == null ? new JSONArray() : records; + } + + /** + * 将接口记录转换为工号到手机号的映射,过滤缺少工号或手机号的无效数据。 + */ + private Map collectTeacherMobileMap(List rawDataList) { + Map mobileMap = new LinkedHashMap<>(); + for (JSONObject rawData : rawDataList) { + String loginname = StrUtil.trimToEmpty(rawData.getStr("gh")); + String mobile = StrUtil.trimToEmpty(rawData.getStr("sjh")); + if (StrUtil.isAllNotBlank(loginname, mobile)) { + mobileMap.put(loginname, mobile); + } + } + log.info("教职工手机号有效数据整理完成:rawCount={}, validCount={}", rawDataList.size(), mobileMap.size()); + return mobileMap; + } + + /** + * 分批匹配 sys_user.loginname 并更新手机号,避免一次性 IN 条件过长。 + */ + private NutMap updateTeacherMobile(Map mobileMap) { + if (mobileMap == null || mobileMap.isEmpty()) { + return NutMap.NEW().addv("matchedUserCount", 0).addv("updatedUserCount", 0); + } + int matchedUserCount = 0; + int updatedUserCount = 0; + List loginNames = new ArrayList<>(mobileMap.keySet()); + for (int start = 0; start < loginNames.size(); start += TEACHER_MOBILE_DB_BATCH_SIZE) { + int end = Math.min(start + TEACHER_MOBILE_DB_BATCH_SIZE, loginNames.size()); + List batchLoginNames = loginNames.subList(start, end); + List users = dao().query(Sys_user.class, Cnd.where(Sys_user::getLoginname, "in", batchLoginNames)); + matchedUserCount += users.size(); + for (Sys_user user : users) { + String newMobile = mobileMap.get(user.getLoginname()); + if (StrUtil.isNotBlank(newMobile) && !Objects.equals(newMobile, user.getMobile())) { + updatedUserCount += dao().update(Sys_user.class, Chain.make("mobile", newMobile), Cnd.where(Sys_user::getId, "=", user.getId())); + } + } + log.info("教职工手机号批次更新完成:batchStart={}, batchEnd={}, batchMatched={}, updatedTotal={}", + start, end, users.size(), updatedUserCount); + } + return NutMap.NEW().addv("matchedUserCount", matchedUserCount).addv("updatedUserCount", updatedUserCount); + } @Override public Map pullFinance() { diff --git a/src/main/java/com/budwk/app/sys/utils/SysFileMinIoUtil.java b/src/main/java/com/budwk/app/sys/utils/SysFileMinIoUtil.java index 2b20a305..f65fca74 100644 --- a/src/main/java/com/budwk/app/sys/utils/SysFileMinIoUtil.java +++ b/src/main/java/com/budwk/app/sys/utils/SysFileMinIoUtil.java @@ -25,6 +25,7 @@ import com.budwk.app.base.exception.BaseException; import io.minio.*; import io.minio.http.Method; import lombok.extern.slf4j.Slf4j; +import org.nutz.boot.AppContext; import org.nutz.ioc.Ioc; import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.mvc.Mvcs; @@ -49,7 +50,7 @@ public class SysFileMinIoUtil { * 初始化操作的客户端 */ private static void initClient() { - Ioc ioc = Mvcs.getIoc(); + Ioc ioc = AppContext.getDefault().getIoc(); PropertiesProxy propertiesProxy = ioc.get(PropertiesProxy.class, "conf"); String accessKey = propertiesProxy.get("minio.accessKey"); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/thirtyTeachTour/controller/ThirtyTeachTourLedgerController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/thirtyTeachTour/controller/ThirtyTeachTourLedgerController.java index f3c8066e..1f55e027 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/thirtyTeachTour/controller/ThirtyTeachTourLedgerController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/thirtyTeachTour/controller/ThirtyTeachTourLedgerController.java @@ -91,8 +91,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -103,6 +111,11 @@ import java.util.zip.ZipOutputStream; @Slf4j public class ThirtyTeachTourLedgerController { + private static final int LEDGER_EXPORT_IMAGE_THREAD_COUNT = 3; + private static final int LEDGER_EXPORT_FILE_QUERY_BATCH_SIZE = 500; + private static final int LEDGER_EXPORT_IMAGE_TASK_TIMEOUT_SECONDS = 300; + private static final String LEDGER_EXPORT_ROW_IMAGES_KEY = "__ledgerExportImages"; + @Inject private ThirtyTeachTourLedgerService tourLedgerService; @@ -379,6 +392,7 @@ public class ThirtyTeachTourLedgerController { entities.add(new ExcelExportEntity("年龄", "age", 10)); entities.add(new ExcelExportEntity("身份证号", "idCard", 24)); entities.add(new ExcelExportEntity("手机号", "mobile", 18)); + entities.add(new ExcelExportEntity("所属工会", "unionName", 18)); entities.add(new ExcelExportEntity("所在单位", "unitName", 26)); entities.add(new ExcelExportEntity("乘车地点", "boardingPlace", 18)); entities.add(new ExcelExportEntity("报名线路", "lineName", 30)); @@ -405,12 +419,13 @@ public class ThirtyTeachTourLedgerController { ExcelExportEntity imageEntity = new ExcelExportEntity("当前时间段图片" + i, "photoCell" + (maxPhotoCount + i), 18); entities.add(imageEntity); } + Map imageMap = batchResolveLedgerImages(rows); rows.forEach(row -> { Integer familyCount = row.getInt("familyCount"); row.put("familyCountText", (familyCount == null ? 0 : familyCount) + "人"); row.put("joinedText", Boolean.TRUE.equals(row.getBoolean("joined")) ? "是" : "否"); row.put("overCostReimbursedText", Boolean.TRUE.equals(row.getBoolean("overCostReimbursed")) ? "是" : "否"); - fillLedgerPhotoRowImages(row, maxPhotoCount, maxCurrentPeriodPhotoCount); + fillLedgerPhotoRowImages(row, maxPhotoCount, maxCurrentPeriodPhotoCount, imageMap); }); List> rowImages = snapshotLedgerRowImages(rows); @@ -439,22 +454,23 @@ public class ThirtyTeachTourLedgerController { /** * 将每条台账的图片材料解析为可写入 Excel 的图片对象,图片列文本保持为空,后续由 POI 手动插入图片。 */ - private void fillLedgerPhotoRowImages(NutMap row, int maxPhotoCount, int maxCurrentPeriodPhotoCount) { + private void fillLedgerPhotoRowImages(NutMap row, int maxPhotoCount, int maxCurrentPeriodPhotoCount, + Map imageMap) { List photoFiles = splitPhotoFiles(row.getString("photoFiles", "")); List currentPeriodPhotoFiles = splitPhotoFiles(row.getString("currentPeriodPhotoFiles", "")); if (!photoFiles.isEmpty()) { - log.info("三十年教龄疗休养台账30年前图片字段读取:jobNo={}, userName={}, photoCount={}, photoFiles={}", + log.debug("三十年教龄疗休养台账30年前图片字段读取:jobNo={}, userName={}, photoCount={}, photoFiles={}", row.getString("jobNo", ""), row.getString("userName", ""), photoFiles.size(), row.getString("photoFiles", "")); } if (!currentPeriodPhotoFiles.isEmpty()) { - log.info("三十年教龄疗休养台账当前时间段图片字段读取:jobNo={}, userName={}, photoCount={}, photoFiles={}", + log.debug("三十年教龄疗休养台账当前时间段图片字段读取:jobNo={}, userName={}, photoCount={}, photoFiles={}", row.getString("jobNo", ""), row.getString("userName", ""), currentPeriodPhotoFiles.size(), row.getString("currentPeriodPhotoFiles", "")); } // 图片对象按导出列顺序组装:先 30 年前图片,再当前时间段图片,确保同一教职工图片仍在同一行。 List photoImages = new ArrayList<>(); - photoImages.addAll(resolveLedgerImages(row.getString("photoFiles", ""), 0)); - photoImages.addAll(resolveLedgerImages(row.getString("currentPeriodPhotoFiles", ""), maxPhotoCount)); - row.put("photoImages", photoImages); + photoImages.addAll(resolveLedgerImages(row.getString("photoFiles", ""), 0, imageMap)); + photoImages.addAll(resolveLedgerImages(row.getString("currentPeriodPhotoFiles", ""), maxPhotoCount, imageMap)); + row.put(LEDGER_EXPORT_ROW_IMAGES_KEY, photoImages); for (int i = 1; i <= maxPhotoCount + maxCurrentPeriodPhotoCount; i++) { row.put("photoCell" + i, ""); } @@ -490,7 +506,7 @@ public class ThirtyTeachTourLedgerController { } for (NutMap row : rows) { List images = new ArrayList<>(); - Object photoImageData = row.get("photoImages"); + Object photoImageData = row.get(LEDGER_EXPORT_ROW_IMAGES_KEY); if (photoImageData instanceof List) { for (Object image : (List) photoImageData) { if (image instanceof LedgerExportImage) { @@ -532,7 +548,7 @@ public class ThirtyTeachTourLedgerController { anchor.setRow2(excelRowIndex + 1); drawing.createPicture(anchor, pictureIndex); insertedPictureCount++; - log.info("三十年教龄疗休养台账图片写入Excel成功:rowIndex={}, columnOffset={}, fileId={}, bytes={}, pictureType={}", + log.debug("三十年教龄疗休养台账图片写入Excel成功:rowIndex={}, columnOffset={}, fileId={}, bytes={}, pictureType={}", excelRowIndex, image.columnOffset, image.fileId, image.bytes.length, image.pictureType); } } @@ -540,21 +556,193 @@ public class ThirtyTeachTourLedgerController { } /** - * 将台账图片字段解析成可插入 Excel 的图片对象,当前优先支持系统文件下载路径。 + * 先收集本次导出涉及的全部图片,再批量查询 sys_file 并并行读取图片字节,避免逐行逐图查库。 + * 图片字节处理仅生成不可变结果,Excel Workbook 仍在后续单线程写入,避免 POI 对象并发访问。 */ - private List resolveLedgerImages(String photoFiles) { - return resolveLedgerImages(photoFiles, 0); + private Map batchResolveLedgerImages(List rows) { + Map> pathCandidateMap = collectLedgerPhotoPathCandidates(rows); + List normalizedPaths = new ArrayList<>(pathCandidateMap.keySet()); + if (normalizedPaths.isEmpty()) { + return Collections.emptyMap(); + } + Map sysFileMap = batchFetchSysFiles(pathCandidateMap); + Map imageMap = new LinkedHashMap<>(); + ExecutorService executorService = Executors.newFixedThreadPool(Math.min(LEDGER_EXPORT_IMAGE_THREAD_COUNT, normalizedPaths.size())); + List> futures = new ArrayList<>(); + for (String normalizedPath : normalizedPaths) { + Sys_file sysFile = sysFileMap.get(normalizedPath); + if (sysFile == null) { + sysFile = sysFileMap.get(extractIdParam(normalizedPath)); + } + Sys_file resolvedSysFile = sysFile; + futures.add(executorService.submit(() -> new LedgerExportImageResolveResult(normalizedPath, + resolveLedgerImage(normalizedPath, resolvedSysFile)))); + } + executorService.shutdown(); + for (Future future : futures) { + try { + LedgerExportImageResolveResult result = future.get(LEDGER_EXPORT_IMAGE_TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (result != null && result.image != null) { + imageMap.put(result.normalizedPath, result.image); + if (StrUtil.isNotBlank(result.image.fileId)) { + imageMap.putIfAbsent(result.image.fileId, result.image); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("三十年教龄疗休养台账导出图片并行读取被中断:msg={}", e.getMessage()); + break; + } catch (ExecutionException e) { + log.warn("三十年教龄疗休养台账导出图片并行读取失败:msg={}", e.getMessage()); + } catch (TimeoutException e) { + future.cancel(true); + log.warn("三十年教龄疗休养台账导出图片并行读取超时:timeoutSeconds={}", LEDGER_EXPORT_IMAGE_TASK_TIMEOUT_SECONDS); + } + } + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + executorService.shutdownNow(); + } + log.info("三十年教龄疗休养台账导出图片批量解析完成:pathCount={}, sysFileCount={}, imageCount={}", + normalizedPaths.size(), sysFileMap.size(), imageMap.size()); + return imageMap; } /** - * 将台账图片字段解析成可插入 Excel 的图片对象,并记录图片列偏移,支持多组图片列分区写入。 + * 按台账图片字段原始顺序收集并去重,同时保留原始路径候选值用于批量匹配历史 sys_file.downloadPath。 */ - private List resolveLedgerImages(String photoFiles, int columnOffsetStart) { + private Map> collectLedgerPhotoPathCandidates(List rows) { + if (rows == null || rows.isEmpty()) { + return Collections.emptyMap(); + } + Map> pathCandidateMap = new LinkedHashMap<>(); + for (NutMap row : rows) { + splitPhotoFiles(row.getString("photoFiles", "")).forEach(path -> addPhotoPathCandidates(pathCandidateMap, path)); + splitPhotoFiles(row.getString("currentPeriodPhotoFiles", "")).forEach(path -> addPhotoPathCandidates(pathCandidateMap, path)); + } + return pathCandidateMap; + } + + /** + * 同一图片用规范化路径作为回填 key,并保留原始值、解码值和规范化值,避免批量查询漏掉历史保存格式。 + */ + private void addPhotoPathCandidates(Map> pathCandidateMap, String photoPath) { + String originalPath = StrUtil.trimToEmpty(photoPath); + String normalizedPath = normalizePhotoPath(photoPath); + if (StrUtil.isNotBlank(normalizedPath)) { + Set candidates = pathCandidateMap.computeIfAbsent(normalizedPath, key -> new LinkedHashSet<>()); + addPhotoPathCandidate(candidates, originalPath); + addPhotoPathCandidate(candidates, decodePhotoPath(originalPath)); + addPhotoPathCandidate(candidates, normalizedPath); + if (StrUtil.isNotBlank(Globals.AppDomain) && normalizedPath.startsWith("/")) { + addPhotoPathCandidate(candidates, Globals.AppDomain + normalizedPath); + } + } + } + + /** + * 图片路径候选值统一入集合,过滤空值,避免 IN 条件中出现无效参数。 + */ + private void addPhotoPathCandidate(Set candidates, String candidate) { + if (StrUtil.isNotBlank(candidate)) { + candidates.add(candidate); + } + } + + /** + * 批量查询本次导出涉及的 sys_file 记录,同时兼容按文件 ID、原始 downloadPath 和规范化 downloadPath 保存的历史图片路径。 + */ + private Map batchFetchSysFiles(Map> pathCandidateMap) { + if (pathCandidateMap == null || pathCandidateMap.isEmpty()) { + return Collections.emptyMap(); + } + Set fileIds = new LinkedHashSet<>(); + Set downloadPaths = new LinkedHashSet<>(); + for (Map.Entry> entry : pathCandidateMap.entrySet()) { + String normalizedPath = entry.getKey(); + String fileId = extractIdParam(normalizedPath); + if (StrUtil.isNotBlank(fileId)) { + fileIds.add(fileId); + } + for (String candidate : entry.getValue()) { + String candidateFileId = extractIdParam(candidate); + if (StrUtil.isNotBlank(candidateFileId)) { + fileIds.add(candidateFileId); + } + downloadPaths.add(candidate); + } + } + Map result = new LinkedHashMap<>(); + querySysFilesByIds(fileIds).forEach(file -> putSysFileIndexes(result, file)); + querySysFilesByDownloadPaths(downloadPaths).forEach(file -> putSysFileIndexes(result, file)); + return result; + } + + /** + * sys_file 按文件 ID 批量查询,使用模型字段引用兼容 @Name 主键字段映射。 + */ + private List querySysFilesByIds(Set values) { + if (values == null || values.isEmpty()) { + return Collections.emptyList(); + } + List valueList = new ArrayList<>(values); + List files = new ArrayList<>(); + for (int start = 0; start < valueList.size(); start += LEDGER_EXPORT_FILE_QUERY_BATCH_SIZE) { + int end = Math.min(start + LEDGER_EXPORT_FILE_QUERY_BATCH_SIZE, valueList.size()); + files.addAll(sysFileService.query(Cnd.where(Sys_file::getId, "in", valueList.subList(start, end)))); + } + return files; + } + + /** + * sys_file 按下载路径批量查询,按固定大小分片避免超长 IN 条件影响数据库解析。 + */ + private List querySysFilesByDownloadPaths(Set values) { + if (values == null || values.isEmpty()) { + return Collections.emptyList(); + } + List valueList = new ArrayList<>(values); + List files = new ArrayList<>(); + for (int start = 0; start < valueList.size(); start += LEDGER_EXPORT_FILE_QUERY_BATCH_SIZE) { + int end = Math.min(start + LEDGER_EXPORT_FILE_QUERY_BATCH_SIZE, valueList.size()); + files.addAll(sysFileService.query(Cnd.where(Sys_file::getDownloadPath, "in", valueList.subList(start, end)))); + } + return files; + } + + /** + * 同一文件同时建立 id 和 downloadPath 索引,行内回填时统一用规范化路径查找。 + */ + private void putSysFileIndexes(Map result, Sys_file file) { + if (file == null) { + return; + } + if (StrUtil.isNotBlank(file.getId())) { + result.putIfAbsent(file.getId(), file); + } + if (StrUtil.isNotBlank(file.getDownloadPath())) { + result.putIfAbsent(normalizePhotoPath(file.getDownloadPath()), file); + } + } + + /** + * 将台账图片字段解析成可插入 Excel 的图片对象,并记录图片列偏移,图片字节来自批量预解析结果。 + */ + private List resolveLedgerImages(String photoFiles, int columnOffsetStart, + Map imageMap) { List images = new ArrayList<>(); List photoPaths = splitPhotoFiles(photoFiles); for (int photoIndex = 0; photoIndex < photoPaths.size(); photoIndex++) { String originalPath = photoPaths.get(photoIndex); - LedgerExportImage image = resolveLedgerImage(originalPath); + String normalizedPath = normalizePhotoPath(originalPath); + LedgerExportImage image = imageMap == null ? null : imageMap.get(normalizedPath); + if (image == null && imageMap != null) { + image = imageMap.get(extractIdParam(normalizedPath)); + } if (image != null) { images.add(image.withColumnOffset(columnOffsetStart + photoIndex)); } else { @@ -567,13 +755,10 @@ public class ThirtyTeachTourLedgerController { /** * 根据后台保存的下载路径定位 sys_file,并读取真实图片字节及 POI 可识别的图片类型。 */ - private LedgerExportImage resolveLedgerImage(String originalPath) { - String normalizedPath = normalizePhotoPath(originalPath); + private LedgerExportImage resolveLedgerImage(String normalizedPath, Sys_file sysFile) { String fileId = extractIdParam(normalizedPath); - Sys_file sysFile = fetchSysFile(fileId, normalizedPath); if (sysFile == null) { - log.warn("三十年教龄疗休养台账图片文件记录不存在:fileId={}, originalPath={}, normalizedPath={}", - fileId, originalPath, normalizedPath); + log.warn("三十年教龄疗休养台账图片文件记录不存在:fileId={}, normalizedPath={}", fileId, normalizedPath); return null; } byte[] bytes = readSysFileBytes(sysFile); @@ -587,25 +772,11 @@ public class ThirtyTeachTourLedgerController { sysFile.getId(), sysFile.getEngine(), sysFile.getStoragePath(), bytes == null ? 0 : bytes.length, pictureType); return null; } - log.info("三十年教龄疗休养台账图片读取成功:fileId={}, engine={}, bucket={}, storagePath={}, bytes={}, pictureType={}", + log.debug("三十年教龄疗休养台账图片读取成功:fileId={}, engine={}, bucket={}, storagePath={}, bytes={}, pictureType={}", sysFile.getId(), sysFile.getEngine(), sysFile.getBucket(), sysFile.getStoragePath(), bytes.length, pictureType); return new LedgerExportImage(sysFile.getId(), normalizedPath, bytes, pictureType); } - /** - * 优先按文件ID查询,失败时按 downloadPath 查询,兼容后台保存的是完整下载路径的场景。 - */ - private Sys_file fetchSysFile(String fileId, String normalizedPath) { - Sys_file sysFile = null; - if (StrUtil.isNotBlank(fileId)) { - sysFile = sysFileService.fetch(fileId); - } - if (sysFile == null && StrUtil.isNotBlank(normalizedPath)) { - sysFile = sysFileService.fetch(Cnd.where(Sys_file::getDownloadPath, "=", normalizedPath)); - } - return sysFile; - } - /** * 根据图片字节头判断 POI 图片类型,无法识别时尝试通过 ImageIO 转为 PNG。 */ @@ -703,14 +874,10 @@ public class ThirtyTeachTourLedgerController { * 将完整URL规整为站内相对路径,避免导出时重复拼接系统域名。 */ private String normalizePhotoPath(String photoFile) { - String path = StrUtil.trimToEmpty(photoFile); + String path = decodePhotoPath(photoFile); if (StrUtil.isBlank(path)) { return ""; } - try { - path = URLDecoder.decode(path, StandardCharsets.UTF_8.toString()); - } catch (Exception ignored) { - } if (StrUtil.isNotBlank(Globals.AppDomain) && path.startsWith(Globals.AppDomain)) { path = path.substring(Globals.AppDomain.length()); } @@ -722,6 +889,21 @@ public class ThirtyTeachTourLedgerController { return path; } + /** + * 图片路径可能由前端 URL 编码后保存,统一解码后再参与匹配和规范化处理。 + */ + private String decodePhotoPath(String photoFile) { + String path = StrUtil.trimToEmpty(photoFile); + if (StrUtil.isBlank(path)) { + return ""; + } + try { + return URLDecoder.decode(path, StandardCharsets.UTF_8.toString()); + } catch (Exception ignored) { + return path; + } + } + /** * 台账导出图片对象,保存 POI 写图所需的图片字节、类型和来源文件信息。 */ @@ -749,6 +931,19 @@ public class ThirtyTeachTourLedgerController { } } + /** + * 图片并行读取任务的返回对象,保留规范化路径用于回填到导出行。 + */ + private static class LedgerExportImageResolveResult { + private final String normalizedPath; + private final LedgerExportImage image; + + private LedgerExportImageResolveResult(String normalizedPath, LedgerExportImage image) { + this.normalizedPath = normalizedPath; + this.image = image; + } + } + private List queryLedgerExportRows(Integer startYear, Integer endYear, String keyword, String unionId, String lineId, String travelPeriod, String lineType, Boolean directFamilyOnly, Boolean overCostOnly) { Cnd cnd = buildQueryCnd(startYear, endYear, keyword, unionId, lineId, travelPeriod, lineType, directFamilyOnly, overCostOnly); @@ -761,6 +956,7 @@ public class ThirtyTeachTourLedgerController { COALESCE(t.age, vu.age) AS age, COALESCE(NULLIF(t.idCard, ''), NULLIF(vu.idCard, ''), '') AS idCard, COALESCE(NULLIF(t.mobile, ''), NULLIF(vu.mobile, ''), '') AS mobile, + COALESCE(NULLIF(t.unionName, ''), vu.unionName, '') AS unionName, t.unitName, t.boardingPlace, COALESCE(NULLIF(l.lineName, ''), t.lineName, '') AS lineName, diff --git a/src/main/resources/db/thirtyTeachTour/thirty_teach_tour_add_current_period_photo_files.sql b/src/main/resources/db/thirtyTeachTour/thirty_teach_tour_add_current_period_photo_files.sql new file mode 100644 index 00000000..f8e0c737 --- /dev/null +++ b/src/main/resources/db/thirtyTeachTour/thirty_teach_tour_add_current_period_photo_files.sql @@ -0,0 +1,6 @@ +-- 30年教龄疗休养当前时间段图片材料字段。 +ALTER TABLE `thirty_teach_tour_user_assignment` + ADD COLUMN `currentPeriodPhotoFiles` text DEFAULT NULL COMMENT '当前时间段图片材料' AFTER `photoFiles`; + +ALTER TABLE `thirty_teach_tour_ledger` + ADD COLUMN `currentPeriodPhotoFiles` text DEFAULT NULL COMMENT '当前时间段图片材料' AFTER `photoFiles`; diff --git a/src/main/resources/views/platform/zhgh/dayofficework/ThirtyTeachTour/ledger/index.html b/src/main/resources/views/platform/zhgh/dayofficework/ThirtyTeachTour/ledger/index.html index 5e145c6e..1cfd4713 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/ThirtyTeachTour/ledger/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/ThirtyTeachTour/ledger/index.html @@ -109,6 +109,7 @@ layout("/layouts/platform.html"){ +