feat: 专家执业证书/职称证明批量导入 (zip 按手机号/姓名匹配回填)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+32
@@ -1,6 +1,8 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -169,4 +171,34 @@ public class BizExpertController extends BaseController
|
||||
ImportResult result = bizExpertService.importExpert(file, updateSupport, operName);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入执业证书 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 practice_cert_url.
|
||||
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
|
||||
*/
|
||||
@Log(title = "专家执业证书导入", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/importPracticeCert")
|
||||
public AjaxResult importPracticeCert(MultipartFile file) throws Exception
|
||||
{
|
||||
String operName = SecurityUtils.getUsername();
|
||||
String jobId = bizExpertService.submitCertImport(file, "practice", operName);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("jobId", jobId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入职称证明 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 title_cert_url.
|
||||
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
|
||||
*/
|
||||
@Log(title = "专家职称证明导入", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/importTitleCert")
|
||||
public AjaxResult importTitleCert(MultipartFile file) throws Exception
|
||||
{
|
||||
String operName = SecurityUtils.getUsername();
|
||||
String jobId = bizExpertService.submitCertImport(file, "title", operName);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("jobId", jobId);
|
||||
return success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,17 @@ public interface IBizExpertService
|
||||
* @return 导入结果
|
||||
*/
|
||||
ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception;
|
||||
|
||||
/**
|
||||
* 批量导入证书文件 (执业证书 / 职称证明): 提交即返回 jobId, 后台线程解压 zip (或单文件),
|
||||
* 按文件名匹配专家 (手机号优先, 姓名唯一命中兜底), 逐个上传 OSS 并覆盖式回填
|
||||
* practiceCertUrl (certType="practice") 或 titleCertUrl (certType="title").
|
||||
* 前端轮询 {@code GET /business/upload/progress/{jobId}} 读进度与结果.
|
||||
*
|
||||
* @param file .zip 压缩包 或单个 .png/.jpg/.jpeg/.pdf/.webp 文件
|
||||
* @param certType "practice" | "title"
|
||||
* @param operName 操作人 (写入 update_by)
|
||||
* @return jobId (进度查询凭据)
|
||||
*/
|
||||
String submitCertImport(MultipartFile file, String certType, String operName) throws Exception;
|
||||
}
|
||||
|
||||
+254
@@ -1,14 +1,28 @@
|
||||
package com.ruoyi.business.service.impl;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.MalformedInputException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -17,7 +31,9 @@ import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.BizExpertImportVo;
|
||||
import com.ruoyi.business.mapper.BizExpertMapper;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.oss.OssUploader;
|
||||
import com.ruoyi.business.service.IBizExpertService;
|
||||
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
@@ -46,6 +62,24 @@ public class BizExpertServiceImpl implements IBizExpertService
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
@Autowired
|
||||
private OssUploader ossUploader;
|
||||
|
||||
@Autowired
|
||||
private UploadProgressRegistry uploadProgressRegistry;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("zipUploadExecutor")
|
||||
private ExecutorService zipUploadExecutor;
|
||||
|
||||
/** 证书类型: 执业证书 → practiceCertUrl + OSS 子目录 ry8080/cert/practice */
|
||||
private static final String CERT_TYPE_PRACTICE = "practice";
|
||||
/** 证书类型: 职称证明 → titleCertUrl + OSS 子目录 ry8080/cert/title */
|
||||
private static final String CERT_TYPE_TITLE = "title";
|
||||
|
||||
/** 手机号 (1 开头 11 位) 匹配模式, 用于从证书文件名里提取专家标识 */
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("1\\d{10}");
|
||||
|
||||
@Override
|
||||
public BizExpert getById(Long expertId)
|
||||
{ return bizExpertMapper.selectByPrimaryKey(expertId); }
|
||||
@@ -438,4 +472,224 @@ public class BizExpertServiceImpl implements IBizExpertService
|
||||
this.rowNo = rowNo;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 证书批量导入 (执业证书 / 职称证明) ====================
|
||||
|
||||
@Override
|
||||
public String submitCertImport(MultipartFile file, String certType, String operName) throws Exception {
|
||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的证书文件");
|
||||
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
|
||||
boolean title = CERT_TYPE_TITLE.equals(certType);
|
||||
if (!practice && !title) throw new ServiceException("未知证书类型: " + certType);
|
||||
|
||||
String jobId = UUID.randomUUID().toString().replace("-", "");
|
||||
byte[] fileBytes = file.getBytes();
|
||||
String originalName = file.getOriginalFilename();
|
||||
uploadProgressRegistry.start(jobId);
|
||||
// 后台线程无 SecurityContext, 操作人已在请求线程 (controller) 取好
|
||||
final String type = certType;
|
||||
final String username = operName;
|
||||
zipUploadExecutor.submit(() -> {
|
||||
try {
|
||||
Map<String, Object> result = doCertImport(fileBytes, originalName, type, jobId, username);
|
||||
uploadProgressRegistry.finish(jobId, result);
|
||||
} catch (Exception e) {
|
||||
log.warn("[expert] 证书批量回填失败 type={} err={}", type, e.getMessage(), e);
|
||||
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
|
||||
}
|
||||
});
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/** 后台实际处理: 解压 → 匹配 → 上传 OSS → 回填 URL */
|
||||
private Map<String, Object> doCertImport(byte[] fileBytes, String originalName, String certType,
|
||||
String jobId, String username) throws Exception {
|
||||
List<CertFile> files = parseCertFiles(fileBytes, originalName);
|
||||
if (files.isEmpty()) {
|
||||
throw new ServiceException("压缩包内没有可识别的证书文件 (支持 .png/.jpg/.jpeg/.pdf/.webp)");
|
||||
}
|
||||
|
||||
// 一次性加载所有专家, 构建 phone/name 索引, 避免每文件一次 SQL
|
||||
List<BizExpert> allExperts = bizExpertMapper.selectList(new BizExpert());
|
||||
Map<String, BizExpert> byPhone = new HashMap<>();
|
||||
Map<String, List<BizExpert>> byName = new HashMap<>();
|
||||
for (BizExpert e : allExperts) {
|
||||
if (e.getPhone() != null && !e.getPhone().isEmpty()) byPhone.put(e.getPhone(), e);
|
||||
if (e.getName() != null && !e.getName().trim().isEmpty()) {
|
||||
byName.computeIfAbsent(e.getName().trim(), k -> new ArrayList<>()).add(e);
|
||||
}
|
||||
}
|
||||
|
||||
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
|
||||
String subDir = practice ? "ry8080/cert/practice" : "ry8080/cert/title";
|
||||
uploadProgressRegistry.setTotal(jobId, files.size());
|
||||
|
||||
int updated = 0;
|
||||
int processed = 0;
|
||||
// expertId -> 已拼接的 URL (处理同一专家多文件时避免互相覆盖)
|
||||
Map<Long, String> accumulated = new HashMap<>();
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (CertFile f : files) {
|
||||
processed++;
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("fileName", f.name);
|
||||
CertMatch cm = matchExpert(f.name, byPhone, byName);
|
||||
if (cm.expert == null) {
|
||||
r.put("matched", false);
|
||||
r.put("reason", cm.reason);
|
||||
results.add(r);
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
continue;
|
||||
}
|
||||
BizExpert matched = cm.expert;
|
||||
|
||||
String url = ossUploader.uploadWithOriginalName(f.bytes, f.name, subDir);
|
||||
String existing = accumulated.containsKey(matched.getExpertId())
|
||||
? accumulated.get(matched.getExpertId())
|
||||
: (practice ? matched.getPracticeCertUrl() : matched.getTitleCertUrl());
|
||||
String joined = (existing == null || existing.isEmpty()) ? url : existing + "," + url;
|
||||
accumulated.put(matched.getExpertId(), joined);
|
||||
|
||||
BizExpert entity = new BizExpert();
|
||||
entity.setExpertId(matched.getExpertId());
|
||||
entity.setUpdateBy(username);
|
||||
if (practice) entity.setPracticeCertUrl(joined);
|
||||
else entity.setTitleCertUrl(joined);
|
||||
bizExpertMapper.updateByPrimaryKey(entity);
|
||||
updated++;
|
||||
|
||||
r.put("matched", true);
|
||||
r.put("expertName", matched.getName());
|
||||
r.put("phone", matched.getPhone());
|
||||
results.add(r);
|
||||
uploadProgressRegistry.update(jobId, processed);
|
||||
}
|
||||
log.info("[expert] 证书回填完成 type={} 命中{}个 (跳过{}个)", certType, updated, files.size() - updated);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", updated);
|
||||
result.put("total", files.size());
|
||||
result.put("skipped", files.size() - updated);
|
||||
result.put("results", results);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按文件名匹配专家: 手机号优先 (唯一), 姓名兜底 (仅唯一命中才返回).
|
||||
* 文件名先剥扩展名, 手机号从原串提取; 姓名兜底时剔除证书关键词与分隔符.
|
||||
* 未命中返回 {@link CertMatch} (expert=null, reason 区分 手机号不存在 / 姓名重名 / 姓名不存在).
|
||||
*/
|
||||
private CertMatch matchExpert(String fileName, Map<String, BizExpert> byPhone, Map<String, List<BizExpert>> byName) {
|
||||
String base = stripExt(fileName);
|
||||
if (base.isEmpty()) return new CertMatch(null, "文件名无法解析");
|
||||
Matcher m = PHONE_PATTERN.matcher(base);
|
||||
if (m.find()) {
|
||||
String phone = m.group();
|
||||
BizExpert e = byPhone.get(phone);
|
||||
if (e != null) return new CertMatch(e, null);
|
||||
return new CertMatch(null, "手机号 " + phone + " 不存在");
|
||||
}
|
||||
String name = stripCertKeywords(base);
|
||||
if (name.isEmpty()) return new CertMatch(null, "文件名无法解析出手机号或姓名");
|
||||
List<BizExpert> list = byName.get(name);
|
||||
if (list == null || list.isEmpty()) return new CertMatch(null, "姓名 " + name + " 不存在");
|
||||
if (list.size() > 1) return new CertMatch(null, "姓名 " + name + " 重名, 无法唯一定位, 请用手机号命名");
|
||||
return new CertMatch(list.get(0), null);
|
||||
}
|
||||
|
||||
/** 去掉文件名最后一个扩展名 (如 张三.pdf → 张三) */
|
||||
private static String stripExt(String name) {
|
||||
if (name == null) return "";
|
||||
int dot = name.lastIndexOf('.');
|
||||
String s = dot > 0 ? name.substring(0, dot) : name;
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** 剔除证书关键词与分隔符, 供姓名兜底匹配 (执业证书_张三 → 张三) */
|
||||
private static String stripCertKeywords(String base) {
|
||||
String s = base;
|
||||
s = s.replaceAll("(?i)(执业证书|职称证明|职业资格证书|资格证书|职称证书|职称|证书|证明)", " ");
|
||||
s = s.replaceAll("[_\\-\\s]+", " ");
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** 解析输入: zip → 展平所有证书文件; 单文件 → 单条; 不支持的扩展名抛异常 */
|
||||
private List<CertFile> parseCertFiles(byte[] bytes, String originalName) throws IOException {
|
||||
String lower = originalName == null ? "" : originalName.toLowerCase();
|
||||
if (lower.endsWith(".zip")) {
|
||||
return parseCertZip(bytes);
|
||||
}
|
||||
if (isCertExt(originalName)) {
|
||||
List<CertFile> out = new ArrayList<>();
|
||||
out.add(new CertFile(originalName, bytes));
|
||||
return out;
|
||||
}
|
||||
throw new ServiceException("不支持的文件类型, 请上传 .zip 压缩包或 .png/.jpg/.jpeg/.pdf/.webp 文件");
|
||||
}
|
||||
|
||||
/** 解压 zip: UTF-8 失败回退 GBK (中文 Windows 打包器), 展平子目录, 只保留证书文件 */
|
||||
private List<CertFile> parseCertZip(byte[] zipBytes) throws IOException {
|
||||
try {
|
||||
return readCertZip(zipBytes, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException | MalformedInputException e) {
|
||||
log.warn("[expert] 证书 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
|
||||
return readCertZip(zipBytes, Charset.forName("GBK"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<CertFile> readCertZip(byte[] zipBytes, Charset charset) throws IOException {
|
||||
List<CertFile> out = new ArrayList<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.isDirectory()) continue;
|
||||
String path = entry.getName();
|
||||
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
|
||||
String name = lastSegment(path);
|
||||
if (!isCertExt(name)) continue;
|
||||
out.add(new CertFile(name, readAllBytes(zis)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String lastSegment(String path) {
|
||||
int i = path.lastIndexOf('/');
|
||||
return i >= 0 ? path.substring(i + 1) : path;
|
||||
}
|
||||
|
||||
private static boolean isCertExt(String name) {
|
||||
if (name == null) return false;
|
||||
String l = name.toLowerCase();
|
||||
return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg")
|
||||
|| l.endsWith(".pdf") || l.endsWith(".webp");
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** 证书文件 (解压后的单个文件: 文件名 + 字节) */
|
||||
private static class CertFile {
|
||||
final String name;
|
||||
final byte[] bytes;
|
||||
CertFile(String name, byte[] bytes) {
|
||||
this.name = name;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/** 证书文件名 → 专家匹配结果 (expert 为 null 时 reason 说明未命中原因) */
|
||||
private static class CertMatch {
|
||||
final BizExpert expert;
|
||||
final String reason;
|
||||
CertMatch(BizExpert expert, String reason) {
|
||||
this.expert = expert;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user