diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java index f3763e5..02364bc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExpertController.java @@ -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 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 result = new HashMap<>(); + result.put("jobId", jobId); + return success(result); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExpertService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExpertService.java index b7981ab..a074647 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExpertService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizExpertService.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java index a6b93da..5901136 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java @@ -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 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 doCertImport(byte[] fileBytes, String originalName, String certType, + String jobId, String username) throws Exception { + List files = parseCertFiles(fileBytes, originalName); + if (files.isEmpty()) { + throw new ServiceException("压缩包内没有可识别的证书文件 (支持 .png/.jpg/.jpeg/.pdf/.webp)"); + } + + // 一次性加载所有专家, 构建 phone/name 索引, 避免每文件一次 SQL + List allExperts = bizExpertMapper.selectList(new BizExpert()); + Map byPhone = new HashMap<>(); + Map> 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 accumulated = new HashMap<>(); + List> results = new ArrayList<>(); + for (CertFile f : files) { + processed++; + Map 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 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 byPhone, Map> 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 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 parseCertFiles(byte[] bytes, String originalName) throws IOException { + String lower = originalName == null ? "" : originalName.toLowerCase(); + if (lower.endsWith(".zip")) { + return parseCertZip(bytes); + } + if (isCertExt(originalName)) { + List 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 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 readCertZip(byte[] zipBytes, Charset charset) throws IOException { + List 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; + } + } } diff --git a/ry-vue3/src/api/business/expert.js b/ry-vue3/src/api/business/expert.js index aaae85e..16fdca5 100644 --- a/ry-vue3/src/api/business/expert.js +++ b/ry-vue3/src/api/business/expert.js @@ -38,4 +38,42 @@ export function downloadExpertImportTpl() { method: 'get', responseType: 'blob' }) +} + +/** + * 批量导入执业证书 (zip 压缩包或单文件): 后端异步处理, 返回 {jobId}. + * 文件名以手机号命名 (姓名唯一命中兜底), 后端匹配专家并回填 practice_cert_url. + * 上传成功后轮询 queryUploadProgress(jobId) 读进度与结果. + */ +export function importPracticeCert(file) { + const form = new FormData() + form.append('file', file) + return request({ + url: '/business/expert/importPracticeCert', + method: 'post', + data: form, + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** + * 批量导入职称证明 (zip 压缩包或单文件): 同 importPracticeCert, 回填 title_cert_url. + */ +export function importTitleCert(file) { + const form = new FormData() + form.append('file', file) + return request({ + url: '/business/expert/importTitleCert', + method: 'post', + data: form, + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** + * 查询批量上传任务进度 (后端异步处理, 前端轮询). + * @returns {Promise<{code,msg,data:{jobId,done,total,finished,error,message,result}}>} + */ +export function queryUploadProgress(jobId) { + return request({ url: `/business/upload/progress/${jobId}`, method: 'get' }) } \ No newline at end of file diff --git a/ry-vue3/src/views/expert/Experts.vue b/ry-vue3/src/views/expert/Experts.vue index 2bbbc63..4f852db 100644 --- a/ry-vue3/src/views/expert/Experts.vue +++ b/ry-vue3/src/views/expert/Experts.vue @@ -179,22 +179,50 @@ - -

提示: 需将证书文件名称修改为手机号进行导入

- + +

上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg

+ + +
将压缩包/文件拖到此处,或点击上传
+
+
+ + {{ certStatus || '上传中' }} +
- -

提示: 需将证明文件名称修改为手机号进行导入

- + +

上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg

+ + +
将压缩包/文件拖到此处,或点击上传
+
+
+ + {{ certStatus || '上传中' }} +
@@ -204,7 +232,7 @@ @@ -222,11 +250,13 @@