南大三十年教职工疗休养

This commit is contained in:
2026-07-06 14:34:20 +08:00
parent f437c0444d
commit c903e0d934
7 changed files with 397 additions and 41 deletions
@@ -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("删除用户数据")
@@ -38,4 +38,11 @@ public interface SysDataUserPullService extends BaseService<Sys_user_source> {
* @return
*/
Map<String, NutMap> pullFinance();
/**
* 从数据中心分页拉取教职工手机号,并按工号同步到 sys_user.mobile。
*
* @return 同步过程统计信息
*/
NutMap syncTeacherMobile();
}
@@ -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<Sys_user_source> 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<Sys_user_source>
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<JSONObject> rawDataList = new ArrayList<>();
int pageNum = 1;
int pageSize = TEACHER_MOBILE_PAGE_SIZE;
int total = 0;
int pages = 1;
do {
Map<String, Object> 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<String, String> 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<String, Object> buildTeacherMobileRequestBody(int pageNum, int pageSize) {
Map<String, Object> 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<String, String> collectTeacherMobileMap(List<JSONObject> rawDataList) {
Map<String, String> 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<String, String> mobileMap) {
if (mobileMap == null || mobileMap.isEmpty()) {
return NutMap.NEW().addv("matchedUserCount", 0).addv("updatedUserCount", 0);
}
int matchedUserCount = 0;
int updatedUserCount = 0;
List<String> 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<String> batchLoginNames = loginNames.subList(start, end);
List<Sys_user> 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<String, NutMap> pullFinance() {
@@ -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");
@@ -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<String, LedgerExportImage> 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<List<LedgerExportImage>> 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<String, LedgerExportImage> imageMap) {
List<String> photoFiles = splitPhotoFiles(row.getString("photoFiles", ""));
List<String> 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<LedgerExportImage> 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<LedgerExportImage> 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<LedgerExportImage> resolveLedgerImages(String photoFiles) {
return resolveLedgerImages(photoFiles, 0);
private Map<String, LedgerExportImage> batchResolveLedgerImages(List<NutMap> rows) {
Map<String, Set<String>> pathCandidateMap = collectLedgerPhotoPathCandidates(rows);
List<String> normalizedPaths = new ArrayList<>(pathCandidateMap.keySet());
if (normalizedPaths.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Sys_file> sysFileMap = batchFetchSysFiles(pathCandidateMap);
Map<String, LedgerExportImage> imageMap = new LinkedHashMap<>();
ExecutorService executorService = Executors.newFixedThreadPool(Math.min(LEDGER_EXPORT_IMAGE_THREAD_COUNT, normalizedPaths.size()));
List<Future<LedgerExportImageResolveResult>> 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<LedgerExportImageResolveResult> 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<LedgerExportImage> resolveLedgerImages(String photoFiles, int columnOffsetStart) {
private Map<String, Set<String>> collectLedgerPhotoPathCandidates(List<NutMap> rows) {
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Set<String>> 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<String, Set<String>> pathCandidateMap, String photoPath) {
String originalPath = StrUtil.trimToEmpty(photoPath);
String normalizedPath = normalizePhotoPath(photoPath);
if (StrUtil.isNotBlank(normalizedPath)) {
Set<String> 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<String> candidates, String candidate) {
if (StrUtil.isNotBlank(candidate)) {
candidates.add(candidate);
}
}
/**
* 批量查询本次导出涉及的 sys_file 记录,同时兼容按文件 ID、原始 downloadPath 和规范化 downloadPath 保存的历史图片路径。
*/
private Map<String, Sys_file> batchFetchSysFiles(Map<String, Set<String>> pathCandidateMap) {
if (pathCandidateMap == null || pathCandidateMap.isEmpty()) {
return Collections.emptyMap();
}
Set<String> fileIds = new LinkedHashSet<>();
Set<String> downloadPaths = new LinkedHashSet<>();
for (Map.Entry<String, Set<String>> 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<String, Sys_file> 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<Sys_file> querySysFilesByIds(Set<String> values) {
if (values == null || values.isEmpty()) {
return Collections.emptyList();
}
List<String> valueList = new ArrayList<>(values);
List<Sys_file> 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<Sys_file> querySysFilesByDownloadPaths(Set<String> values) {
if (values == null || values.isEmpty()) {
return Collections.emptyList();
}
List<String> valueList = new ArrayList<>(values);
List<Sys_file> 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<String, Sys_file> 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<LedgerExportImage> resolveLedgerImages(String photoFiles, int columnOffsetStart,
Map<String, LedgerExportImage> imageMap) {
List<LedgerExportImage> images = new ArrayList<>();
List<String> 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<NutMap> 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,
@@ -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`;
@@ -109,6 +109,7 @@ layout("/layouts/platform.html"){
<el-table-column label="年龄" prop="age" width="90" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="身份证号" prop="idCard" min-width="180" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="手机号" prop="mobile" width="130" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="所属工会" prop="unionName" min-width="150" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="所在单位" prop="unitName" min-width="180" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="乘车地点" prop="boardingPlace" min-width="140" align="center" header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="报名线路" prop="lineName" min-width="220" sortable="custom" align="center" header-align="center" show-overflow-tooltip></el-table-column>