更新新增三个模块
This commit is contained in:
@@ -20,4 +20,7 @@ public class ConvertRunRequest {
|
||||
@NotBlank(message = "请选择模板")
|
||||
@Schema(description = "模板编码,例如 uk_offer")
|
||||
private String templateId;
|
||||
|
||||
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
|
||||
private String archiveName;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public class UploadedSourceFileDto {
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String originalFilename;
|
||||
|
||||
@Schema(description = "相对上传目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.convert.service;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
@@ -22,24 +23,38 @@ import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertRunService {
|
||||
|
||||
private static final String MODULE_TYPE = "CONVERT";
|
||||
private static final String TEMPLATE_CODE_FIVE_COUNTRIES = "uk_offer";
|
||||
private static final List<String> FIVE_COUNTRY_OUTPUT_FILES = List.of(
|
||||
"\u82f1\u56fd.txt",
|
||||
"\u6cd5\u56fd.txt",
|
||||
"\u5fb7\u56fd.txt",
|
||||
"\u897f\u73ed\u7259.txt",
|
||||
"\u610f\u5927\u5229.txt"
|
||||
);
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final StorageProperties storageProperties;
|
||||
@@ -51,8 +66,8 @@ public class ConvertRunService {
|
||||
ConvertTemplateEntity template = convertTemplateService.getByCode(request.getTemplateId());
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setTaskNo("CONVERT-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType("CONVERT");
|
||||
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setTaskMode("IMMEDIATE");
|
||||
task.setStatus("SUCCESS");
|
||||
task.setSourceFileCount(request.getFiles().size());
|
||||
@@ -63,63 +78,107 @@ public class ConvertRunService {
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
boolean folderMode = request.getArchiveName() != null && !request.getArchiveName().isBlank();
|
||||
List<ConvertResultItemVo> items = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
List<ConvertArchiveEntry> archiveEntries = new ArrayList<>();
|
||||
|
||||
for (UploadedSourceFileDto sourceFile : request.getFiles()) {
|
||||
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
try {
|
||||
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||
if (inputFile == null || !inputFile.exists()) {
|
||||
throw new BusinessException("上传文件不存在,请重新上传");
|
||||
throw new BusinessException("Uploaded source file does not exist, please upload again.");
|
||||
}
|
||||
|
||||
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||
String outputFilename = template.getOutputFilename() == null || template.getOutputFilename().isBlank()
|
||||
? "结果.txt"
|
||||
: template.getOutputFilename();
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||
String inputName = sourceFile.getOriginalFilename() == null
|
||||
? inputFile.getName()
|
||||
: sourceFile.getOriginalFilename();
|
||||
List<GeneratedConvertFile> generatedFiles = generateOutputFiles(inputFile, template);
|
||||
if (folderMode) {
|
||||
for (GeneratedConvertFile generatedFile : generatedFiles) {
|
||||
archiveEntries.add(new ConvertArchiveEntry(sourceFile.getRelativePath(), inputName, generatedFile));
|
||||
}
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
writeTxtByLegacyRules(inputFile, outputFile, template);
|
||||
boolean useZipPackage = request.getFiles().size() == 1 && generatedFiles.size() > 1;
|
||||
File packagedResultFile = useZipPackage
|
||||
? packageGeneratedFilesAsZip(inputName, generatedFiles)
|
||||
: generatedFiles.getFirst().file();
|
||||
String packagedFilename = useZipPackage
|
||||
? FileUtil.mainName(inputName) + ".zip"
|
||||
: generatedFiles.getFirst().filename();
|
||||
String contentType = useZipPackage ? "application/zip" : "text/plain";
|
||||
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "CONVERT");
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(packagedResultFile, MODULE_TYPE);
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||
item.setSourceFilename(inputName);
|
||||
item.setSuccess(true);
|
||||
item.setOutputFilename(outputFilename);
|
||||
item.setOutputFilename(packagedFilename);
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
successCount++;
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("CONVERT");
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(inputName);
|
||||
resultEntity.setResultFilename(outputFilename);
|
||||
resultEntity.setResultFilename(packagedFilename);
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(outputFile.length());
|
||||
resultEntity.setResultContentType("text/plain");
|
||||
resultEntity.setResultFileSize(packagedResultFile.length());
|
||||
resultEntity.setResultContentType(contentType);
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
item.setResultId(resultEntity.getId());
|
||||
|
||||
items.add(item);
|
||||
successCount++;
|
||||
} catch (Exception ex) {
|
||||
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
item.setSuccess(false);
|
||||
item.setError(ex.getMessage());
|
||||
items.add(item);
|
||||
failedCount++;
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("CONVERT");
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
resultEntity.setSuccess(0);
|
||||
resultEntity.setErrorMessage(ex.getMessage());
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
}
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
if (folderMode && !archiveEntries.isEmpty()) {
|
||||
File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries);
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||
item.setSourceFilename(request.getArchiveName());
|
||||
item.setOutputFilename(zipFile.getName());
|
||||
item.setSuccess(true);
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(request.getArchiveName());
|
||||
resultEntity.setResultFilename(zipFile.getName());
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(zipFile.length());
|
||||
resultEntity.setResultContentType("application/zip");
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
item.setResultId(resultEntity.getId());
|
||||
items.add(0, item);
|
||||
}
|
||||
|
||||
task.setSuccessFileCount(successCount);
|
||||
@@ -138,18 +197,18 @@ public class ConvertRunService {
|
||||
|
||||
public List<ConvertResultItemVo> listHistory() {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, "CONVERT")
|
||||
.eq(FileResultEntity::getSuccess, 1)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 50"))
|
||||
.last("limit 200"))
|
||||
.stream()
|
||||
.map(entity -> {
|
||||
ConvertResultItemVo vo = new ConvertResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||
vo.setSourceFilename(entity.getSourceFilename());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||
vo.setSuccess(true);
|
||||
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null);
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
return vo;
|
||||
})
|
||||
.toList();
|
||||
@@ -157,41 +216,71 @@ public class ConvertRunService {
|
||||
|
||||
public void deleteHistory(Long resultId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !"CONVERT".equals(entity.getModuleType())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType())) {
|
||||
throw new BusinessException("Convert result record does not exist.");
|
||||
}
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
|
||||
public File getResultFile(Long resultId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || entity.getResultFileUrl() == null || !"CONVERT".equals(entity.getModuleType())) {
|
||||
throw new BusinessException("结果文件不存在");
|
||||
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType())) {
|
||||
throw new BusinessException("Convert result file does not exist.");
|
||||
}
|
||||
|
||||
File file = new File(entity.getResultFileUrl());
|
||||
if (!file.exists()) {
|
||||
throw new BusinessException("结果文件不存在");
|
||||
throw new BusinessException("Convert result file does not exist.");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private void writeTxtByLegacyRules(File inputFile, File outputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||
private List<GeneratedConvertFile> generateOutputFiles(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||
List<String> outputFilenames = resolveOutputFilenames(templateEntity);
|
||||
List<String> rows = buildTxtRows(inputFile, templateEntity);
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||
|
||||
List<GeneratedConvertFile> generatedFiles = new ArrayList<>();
|
||||
for (String outputFilename : outputFilenames) {
|
||||
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||
FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8);
|
||||
generatedFiles.add(new GeneratedConvertFile(outputFilename, outputFile));
|
||||
}
|
||||
return generatedFiles;
|
||||
}
|
||||
|
||||
private List<String> resolveOutputFilenames(ConvertTemplateEntity templateEntity) {
|
||||
if (TEMPLATE_CODE_FIVE_COUNTRIES.equals(templateEntity.getTemplateCode())) {
|
||||
return FIVE_COUNTRY_OUTPUT_FILES;
|
||||
}
|
||||
|
||||
String outputFilename = templateEntity.getOutputFilename();
|
||||
if (outputFilename == null || outputFilename.isBlank()) {
|
||||
return List.of("result.txt");
|
||||
}
|
||||
return List.of(outputFilename);
|
||||
}
|
||||
|
||||
private List<String> buildTxtRows(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||
List<String> requiredColumns = readStringList(templateEntity.getRequiredSourceColumnsJson());
|
||||
List<String> headerColumns = readStringList(templateEntity.getHeaderColumnsJson());
|
||||
Map<String, String> fieldMapping = readStringMap(templateEntity.getFieldMappingJson());
|
||||
Map<String, String> defaults = readStringMap(templateEntity.getDefaultsJson());
|
||||
List<String> preambleLines = readStringList(templateEntity.getPreambleLinesJson());
|
||||
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null ? 0 : templateEntity.getBlankHeaderRowsAfterSchema();
|
||||
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null
|
||||
? 0
|
||||
: templateEntity.getBlankHeaderRowsAfterSchema();
|
||||
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
|
||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
||||
Workbook workbook = WorkbookFactory.create(fis)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row headerRow = sheet.getRow(0);
|
||||
if (headerRow == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
throw new BusinessException("Excel header row is empty.");
|
||||
}
|
||||
|
||||
Map<String, Integer> headerMap = new HashMap<>();
|
||||
Map<String, Integer> headerMap = new LinkedHashMap<>();
|
||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||
String value = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i)));
|
||||
if (!value.isBlank() && !headerMap.containsKey(value)) {
|
||||
@@ -199,9 +288,11 @@ public class ConvertRunService {
|
||||
}
|
||||
}
|
||||
|
||||
List<String> missing = requiredColumns.stream().filter(column -> !headerMap.containsKey(column)).toList();
|
||||
List<String> missing = requiredColumns.stream()
|
||||
.filter(column -> !headerMap.containsKey(column))
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
|
||||
}
|
||||
|
||||
List<String> rows = new ArrayList<>();
|
||||
@@ -219,6 +310,7 @@ public class ConvertRunService {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String asin = asinIndex >= 0 ? normalizeCellText(formatter.formatCellValue(row.getCell(asinIndex))) : "";
|
||||
if (asin.isBlank()) {
|
||||
continue;
|
||||
@@ -233,7 +325,9 @@ public class ConvertRunService {
|
||||
if (fieldMapping.containsKey(column)) {
|
||||
String sourceColumn = fieldMapping.get(column);
|
||||
Integer sourceIndex = headerMap.get(sourceColumn);
|
||||
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||
String value = sourceIndex == null
|
||||
? ""
|
||||
: normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||
lineValues.add(value);
|
||||
continue;
|
||||
}
|
||||
@@ -243,10 +337,11 @@ public class ConvertRunService {
|
||||
}
|
||||
lineValues.add("");
|
||||
}
|
||||
|
||||
rows.add(String.join("\t", lineValues));
|
||||
rowIndex++;
|
||||
}
|
||||
FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,20 +350,70 @@ public class ConvertRunService {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("模板配置解析失败");
|
||||
throw new BusinessException("Template config parsing failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private File packageGeneratedFilesAsZip(String inputName, List<GeneratedConvertFile> generatedFiles) {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||
File zipFile = buildNamedOutputFile(outputDir, FileUtil.mainName(inputName) + ".zip");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
|
||||
for (GeneratedConvertFile generatedFile : generatedFiles) {
|
||||
ZipEntry entry = new ZipEntry(generatedFile.filename());
|
||||
zos.putNextEntry(entry);
|
||||
Files.copy(generatedFile.file().toPath(), zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Convert result packaging failed: " + ex.getMessage());
|
||||
}
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
private File packageFolderConvertResultsAsZip(String archiveName, List<ConvertArchiveEntry> archiveEntries) {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||
File zipFile = buildNamedOutputFile(outputDir, archiveName + ".zip");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
|
||||
for (ConvertArchiveEntry entry : archiveEntries) {
|
||||
String zipEntryName = buildFolderZipEntry(entry.relativePath(), entry.inputName(), entry.generatedFile().filename(), true);
|
||||
zos.putNextEntry(new ZipEntry(zipEntryName));
|
||||
Files.copy(entry.generatedFile().file().toPath(), zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Convert folder result packaging failed: " + ex.getMessage());
|
||||
}
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
private String buildFolderZipEntry(String relativePath, String inputName, String outputFilename, boolean includeSourceStemFolder) {
|
||||
String normalizedRelativePath = relativePath == null ? "" : relativePath.replace('\\', '/');
|
||||
String relativeDir = normalizedRelativePath.contains("/")
|
||||
? normalizedRelativePath.substring(0, normalizedRelativePath.lastIndexOf('/'))
|
||||
: "";
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (!relativeDir.isBlank()) {
|
||||
parts.add(relativeDir);
|
||||
}
|
||||
if (includeSourceStemFolder) {
|
||||
parts.add(FileUtil.mainName(inputName));
|
||||
}
|
||||
parts.add(outputFilename);
|
||||
return String.join("/", parts);
|
||||
}
|
||||
|
||||
private Map<String, String> readStringMap(String json) {
|
||||
try {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {});
|
||||
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("模板配置解析失败");
|
||||
throw new BusinessException("Template config parsing failed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +422,11 @@ public class ConvertRunService {
|
||||
if (!baseDir.exists()) {
|
||||
return null;
|
||||
}
|
||||
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
||||
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
||||
List<File> matchedFiles = FileUtil.loopFiles(
|
||||
baseDir,
|
||||
pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)
|
||||
);
|
||||
return matchedFiles.isEmpty() ? null : matchedFiles.get(0);
|
||||
}
|
||||
|
||||
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||
@@ -286,6 +434,7 @@ public class ConvertRunService {
|
||||
if (!candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
String mainName = FileUtil.mainName(filename);
|
||||
String extName = FileUtil.extName(filename);
|
||||
int index = 2;
|
||||
@@ -314,4 +463,10 @@ public class ConvertRunService {
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private record GeneratedConvertFile(String filename, File file) {
|
||||
}
|
||||
|
||||
private record ConvertArchiveEntry(String relativePath, String inputName, GeneratedConvertFile generatedFile) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public class DedupeRunController {
|
||||
- 仅保留 selectedColumns 中指定的列;
|
||||
- keepIntegerIds=true 时保留纯数字 ID;
|
||||
- keepUnderscoreIds=true 时保留类似 1_1 的 ID;
|
||||
- keepIntegerMainIdsWhenNoSubIds=true 且文件中不存在 1_1 这类子 ID 时,会自动保留纯数字主 ID;
|
||||
- 输出文件命名遵循 原文件名_cleaned.xlsx,若重名自动追加序号。
|
||||
""")
|
||||
@ApiResponses({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.nanri.aiimage.modules.dedupe.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataUpdateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
|
||||
import com.nanri.aiimage.modules.dedupe.service.DedupeTotalDataService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/dedupe-total-data")
|
||||
@Tag(name = "数据去重总数据", description = "维护数据去重模块的总数据列表,支持增删改查。")
|
||||
public class DedupeTotalDataController {
|
||||
|
||||
private final DedupeTotalDataService dedupeTotalDataService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询总数据", description = "分页查询数据去重总数据,支持按值模糊搜索。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataPageVo.class)))
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataPageVo> page(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword) {
|
||||
return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增总数据", description = "新增一条数据去重总数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataItemVo> create(@Valid @RequestBody DedupeTotalDataCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", dedupeTotalDataService.create(request));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入总数据", description = "上传 xlsx 文件,读取 ASIN 列并批量导入数据去重总数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "导入成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataImportVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataImportVo> importExcel(
|
||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.success("导入成功", dedupeTotalDataService.importFromExcel(file));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新总数据", description = "按 ID 更新一条数据去重总数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataItemVo> update(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Valid @RequestBody DedupeTotalDataUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除总数据", description = "按 ID 删除一条数据去重总数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
||||
})
|
||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
dedupeTotalDataService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.dedupe.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.dedupe.model.entity.DedupeTotalDataEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DedupeTotalDataMapper extends BaseMapper<DedupeTotalDataEntity> {
|
||||
}
|
||||
@@ -21,8 +21,14 @@ public class DedupeRunRequest {
|
||||
private List<String> selectedColumns;
|
||||
|
||||
@Schema(description = "是否保留纯数字 ID")
|
||||
private boolean keepIntegerIds = true;
|
||||
private boolean keepIntegerIds = false;
|
||||
|
||||
@Schema(description = "是否保留带下划线 ID")
|
||||
private boolean keepUnderscoreIds = true;
|
||||
|
||||
@Schema(description = "当文件中不存在下划线子 ID 时,是否自动保留纯数字主 ID")
|
||||
private boolean keepIntegerMainIdsWhenNoSubIds = true;
|
||||
|
||||
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
|
||||
private String archiveName;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public class DedupeSourceFileDto {
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String originalFilename;
|
||||
|
||||
@Schema(description = "相对上传目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据去重总数据新增请求")
|
||||
public class DedupeTotalDataCreateRequest {
|
||||
|
||||
@NotBlank(message = "数据不能为空")
|
||||
@Size(max = 128, message = "数据长度不能超过128个字符")
|
||||
@Schema(description = "总数据值", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String dataValue;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据去重总数据更新请求")
|
||||
public class DedupeTotalDataUpdateRequest {
|
||||
|
||||
@NotBlank(message = "数据不能为空")
|
||||
@Size(max = 128, message = "数据长度不能超过128个字符")
|
||||
@Schema(description = "总数据值", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String dataValue;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_dedupe_total_data")
|
||||
public class DedupeTotalDataEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String dataValue;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据去重总数据导入结果")
|
||||
public class DedupeTotalDataImportVo {
|
||||
|
||||
@Schema(description = "读取总行数")
|
||||
private Integer totalRows;
|
||||
|
||||
@Schema(description = "读取到的 ASIN 数量")
|
||||
private Integer asinCount;
|
||||
|
||||
@Schema(description = "实际新增数量")
|
||||
private Integer insertedCount;
|
||||
|
||||
@Schema(description = "跳过数量")
|
||||
private Integer skippedCount;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据去重总数据项")
|
||||
public class DedupeTotalDataItemVo {
|
||||
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "总数据值")
|
||||
private String dataValue;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据去重总数据分页结果")
|
||||
public class DedupeTotalDataPageVo {
|
||||
|
||||
@Schema(description = "列表")
|
||||
private List<DedupeTotalDataItemVo> items;
|
||||
|
||||
@Schema(description = "总数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "页码")
|
||||
private Long page;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize;
|
||||
}
|
||||
@@ -28,11 +28,14 @@ import org.springframework.stereotype.Service;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
|
||||
@@ -46,7 +49,7 @@ public class DedupeRunService {
|
||||
private final OssStorageService ossStorageService;
|
||||
|
||||
public DedupeRunVo run(DedupeRunRequest request) {
|
||||
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds()) {
|
||||
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) {
|
||||
throw new BusinessException("请至少选择一种 ID 保留规则");
|
||||
}
|
||||
|
||||
@@ -63,9 +66,11 @@ public class DedupeRunService {
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
boolean folderMode = request.getArchiveName() != null && !request.getArchiveName().isBlank();
|
||||
List<DedupeResultItemVo> items = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
List<DedupeArchiveEntry> archiveEntries = new ArrayList<>();
|
||||
|
||||
for (DedupeSourceFileDto sourceFile : request.getFiles()) {
|
||||
DedupeResultItemVo item = new DedupeResultItemVo();
|
||||
@@ -81,13 +86,28 @@ public class DedupeRunService {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result"));
|
||||
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||
|
||||
cleanExcelByLegacyRules(inputFile, outputFile, request.getSelectedColumns(), request.isKeepIntegerIds(), request.isKeepUnderscoreIds());
|
||||
cleanExcelByLegacyRules(
|
||||
inputFile,
|
||||
outputFile,
|
||||
request.getSelectedColumns(),
|
||||
request.isKeepIntegerIds(),
|
||||
request.isKeepUnderscoreIds(),
|
||||
request.isKeepIntegerMainIdsWhenNoSubIds()
|
||||
);
|
||||
|
||||
if (folderMode) {
|
||||
archiveEntries.add(new DedupeArchiveEntry(sourceFile.getRelativePath(), inputName, outputFile));
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE");
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName();
|
||||
|
||||
item.setSuccess(true);
|
||||
item.setOutputFilename(outputFile.getName());
|
||||
item.setOutputFilename(downloadFilename);
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
successCount++;
|
||||
|
||||
@@ -95,7 +115,7 @@ public class DedupeRunService {
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("DEDUPE");
|
||||
resultEntity.setSourceFilename(inputName);
|
||||
resultEntity.setResultFilename(outputFile.getName());
|
||||
resultEntity.setResultFilename(downloadFilename);
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(outputFile.length());
|
||||
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
@@ -119,6 +139,32 @@ public class DedupeRunService {
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
if (folderMode && !archiveEntries.isEmpty()) {
|
||||
File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries);
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE");
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
DedupeResultItemVo item = new DedupeResultItemVo();
|
||||
item.setSourceFilename(request.getArchiveName());
|
||||
item.setOutputFilename(zipFile.getName());
|
||||
item.setSuccess(true);
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("DEDUPE");
|
||||
resultEntity.setSourceFilename(request.getArchiveName());
|
||||
resultEntity.setResultFilename(zipFile.getName());
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(zipFile.length());
|
||||
resultEntity.setResultContentType("application/zip");
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
item.setResultId(resultEntity.getId());
|
||||
items.add(0, item);
|
||||
}
|
||||
|
||||
task.setSuccessFileCount(successCount);
|
||||
task.setFailedFileCount(failedCount);
|
||||
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||
@@ -136,17 +182,17 @@ public class DedupeRunService {
|
||||
public List<DedupeResultItemVo> listHistory() {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, "DEDUPE")
|
||||
.eq(FileResultEntity::getSuccess, 1)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 50"))
|
||||
.stream()
|
||||
.map(entity -> {
|
||||
DedupeResultItemVo vo = new DedupeResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||
vo.setSourceFilename(entity.getSourceFilename());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||
vo.setSuccess(true);
|
||||
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null);
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
return vo;
|
||||
})
|
||||
.toList();
|
||||
@@ -173,7 +219,8 @@ public class DedupeRunService {
|
||||
}
|
||||
|
||||
private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns,
|
||||
boolean keepIntegerIds, boolean keepUnderscoreIds) throws Exception {
|
||||
boolean keepIntegerIds, boolean keepUnderscoreIds,
|
||||
boolean keepIntegerMainIdsWhenNoSubIds) throws Exception {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
||||
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis);
|
||||
@@ -215,6 +262,26 @@ public class DedupeRunService {
|
||||
}
|
||||
|
||||
Integer idColumnIndex = headerMap.get("id");
|
||||
Map<String, Boolean> mainIdHasSubIdMap = new HashMap<>();
|
||||
if (idColumnIndex != null) {
|
||||
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||
Row row = sheet.getRow(rowNum);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
|
||||
String mainId = extractMainId(idValue);
|
||||
if (mainId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!mainIdHasSubIdMap.containsKey(mainId)) {
|
||||
mainIdHasSubIdMap.put(mainId, false);
|
||||
}
|
||||
if (idValue.matches("\\d+_\\d+")) {
|
||||
mainIdHasSubIdMap.put(mainId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
int outputRowIndex = 1;
|
||||
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||
Row row = sheet.getRow(rowNum);
|
||||
@@ -223,7 +290,7 @@ public class DedupeRunService {
|
||||
}
|
||||
if (idColumnIndex != null) {
|
||||
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
|
||||
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds)) {
|
||||
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, mainIdHasSubIdMap)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -242,14 +309,68 @@ public class DedupeRunService {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds) {
|
||||
private File packageFolderDedupeResultsAsZip(String archiveName, List<DedupeArchiveEntry> archiveEntries) {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result"));
|
||||
File zipFile = buildNamedOutputFile(outputDir, archiveName + ".zip");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
|
||||
for (DedupeArchiveEntry entry : archiveEntries) {
|
||||
String zipEntryName = buildFolderZipEntry(entry.relativePath(), entry.inputName(), entry.inputName(), false);
|
||||
zos.putNextEntry(new ZipEntry(zipEntryName));
|
||||
Files.copy(entry.outputFile().toPath(), zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("去重结果打包失败:" + ex.getMessage());
|
||||
}
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
private String buildFolderZipEntry(String relativePath, String inputName, String outputFilename, boolean includeSourceStemFolder) {
|
||||
String normalizedRelativePath = relativePath == null ? "" : relativePath.replace('\\', '/');
|
||||
String relativeDir = normalizedRelativePath.contains("/")
|
||||
? normalizedRelativePath.substring(0, normalizedRelativePath.lastIndexOf('/'))
|
||||
: "";
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (!relativeDir.isBlank()) {
|
||||
parts.add(relativeDir);
|
||||
}
|
||||
if (includeSourceStemFolder) {
|
||||
parts.add(FileUtil.mainName(inputName));
|
||||
}
|
||||
parts.add(outputFilename);
|
||||
return String.join("/", parts);
|
||||
}
|
||||
|
||||
private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds,
|
||||
boolean keepIntegerMainIdsWhenNoSubIds, Map<String, Boolean> mainIdHasSubIdMap) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
if (keepUnderscoreIds && text.matches("\\d+_\\d+")) {
|
||||
return true;
|
||||
}
|
||||
if (keepIntegerIds && text.matches("\\d+")) {
|
||||
return true;
|
||||
}
|
||||
return keepUnderscoreIds && text.matches("\\d+_\\d+");
|
||||
if (keepIntegerMainIdsWhenNoSubIds && text.matches("\\d+")) {
|
||||
String mainId = extractMainId(text);
|
||||
return !mainId.isEmpty() && !Boolean.TRUE.equals(mainIdHasSubIdMap.get(mainId));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractMainId(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
if (text.matches("\\d+")) {
|
||||
return text;
|
||||
}
|
||||
if (text.matches("\\d+_\\d+")) {
|
||||
int idx = text.indexOf('_');
|
||||
return idx > 0 ? text.substring(0, idx) : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private File findLocalSourceFile(String fileKey) {
|
||||
@@ -300,4 +421,7 @@ public class DedupeRunService {
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.nanri.aiimage.modules.dedupe.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataUpdateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.entity.DedupeTotalDataEntity;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DedupeTotalDataService {
|
||||
|
||||
private final DedupeTotalDataMapper dedupeTotalDataMapper;
|
||||
|
||||
public DedupeTotalDataPageVo page(long page, long pageSize, String keyword) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
String safeKeyword = keyword == null ? "" : keyword.trim();
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword)
|
||||
.orderByDesc(DedupeTotalDataEntity::getId);
|
||||
Long total = dedupeTotalDataMapper.selectCount(query);
|
||||
List<DedupeTotalDataItemVo> items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
||||
.stream()
|
||||
.map(this::toItemVo)
|
||||
.toList();
|
||||
DedupeTotalDataPageVo vo = new DedupeTotalDataPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request) {
|
||||
String dataValue = normalizeDataValue(request.getDataValue());
|
||||
ensureUnique(dataValue, null);
|
||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||
entity.setDataValue(dataValue);
|
||||
dedupeTotalDataMapper.insert(entity);
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 文件");
|
||||
}
|
||||
String filename = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase();
|
||||
if (!(filename.endsWith(".xlsx") || filename.endsWith(".xls"))) {
|
||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||
}
|
||||
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
try (InputStream inputStream = file.getInputStream();
|
||||
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(inputStream)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row headerRow = sheet.getRow(0);
|
||||
if (headerRow == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
|
||||
Map<String, Integer> headerMap = new HashMap<>();
|
||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||
Cell cell = headerRow.getCell(i);
|
||||
String value = normalizeExcelText(cell == null ? null : formatter.formatCellValue(cell));
|
||||
if (!value.isBlank() && !headerMap.containsKey(value)) {
|
||||
headerMap.put(value, i);
|
||||
}
|
||||
}
|
||||
|
||||
Integer asinIndex = headerMap.get("ASIN");
|
||||
if (asinIndex == null) {
|
||||
throw new BusinessException("缺少 ASIN 列");
|
||||
}
|
||||
|
||||
Set<String> seenInFile = new HashSet<>();
|
||||
int totalRows = Math.max(sheet.getLastRowNum(), 0);
|
||||
int asinCount = 0;
|
||||
int insertedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||
Row row = sheet.getRow(rowNum);
|
||||
if (row == null) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
String asin = normalizeExcelText(formatter.formatCellValue(row.getCell(asinIndex)));
|
||||
if (asin.isBlank()) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
asinCount++;
|
||||
String dataValue = normalizeDataValue(asin);
|
||||
if (!seenInFile.add(dataValue)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (existsDataValue(dataValue)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||
entity.setDataValue(dataValue);
|
||||
dedupeTotalDataMapper.insert(entity);
|
||||
insertedCount++;
|
||||
}
|
||||
|
||||
DedupeTotalDataImportVo vo = new DedupeTotalDataImportVo();
|
||||
vo.setTotalRows(totalRows);
|
||||
vo.setAsinCount(asinCount);
|
||||
vo.setInsertedCount(insertedCount);
|
||||
vo.setSkippedCount(skippedCount);
|
||||
return vo;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("导入 Excel 失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request) {
|
||||
DedupeTotalDataEntity entity = getById(id);
|
||||
String dataValue = normalizeDataValue(request.getDataValue());
|
||||
ensureUnique(dataValue, id);
|
||||
entity.setDataValue(dataValue);
|
||||
dedupeTotalDataMapper.updateById(entity);
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
DedupeTotalDataEntity entity = getById(id);
|
||||
dedupeTotalDataMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
private DedupeTotalDataEntity getById(Long id) {
|
||||
DedupeTotalDataEntity entity = dedupeTotalDataMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new BusinessException("数据不存在");
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private String normalizeDataValue(String value) {
|
||||
String dataValue = value == null ? "" : value.trim();
|
||||
if (dataValue.isEmpty()) {
|
||||
throw new BusinessException("数据不能为空");
|
||||
}
|
||||
return dataValue;
|
||||
}
|
||||
|
||||
private void ensureUnique(String dataValue, Long excludeId) {
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.eq(DedupeTotalDataEntity::getDataValue, dataValue)
|
||||
.last("LIMIT 1");
|
||||
DedupeTotalDataEntity exists = dedupeTotalDataMapper.selectOne(query);
|
||||
if (exists != null && (excludeId == null || !exists.getId().equals(excludeId))) {
|
||||
throw new BusinessException("数据已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean existsDataValue(String dataValue) {
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.eq(DedupeTotalDataEntity::getDataValue, dataValue)
|
||||
.last("LIMIT 1");
|
||||
return dedupeTotalDataMapper.selectOne(query) != null;
|
||||
}
|
||||
|
||||
private String normalizeExcelText(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", " ")
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private DedupeTotalDataItemVo toItemVo(DedupeTotalDataEntity entity) {
|
||||
DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setDataValue(entity.getDataValue());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,9 @@ public class FileUploadController {
|
||||
})
|
||||
public ApiResponse<UploadFileVo> upload(
|
||||
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
|
||||
MultipartFile file) throws Exception {
|
||||
return ApiResponse.success(localFileStorageService.saveTempFile(file));
|
||||
MultipartFile file,
|
||||
@RequestParam(required = false) String relativePath) throws Exception {
|
||||
return ApiResponse.success(localFileStorageService.saveTempFile(file, relativePath));
|
||||
}
|
||||
|
||||
@GetMapping("/excel-info")
|
||||
|
||||
@@ -18,4 +18,7 @@ public class UploadFileVo {
|
||||
|
||||
@Schema(description = "文件大小")
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "相对上传目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class LocalFileStorageService {
|
||||
|
||||
private final StorageProperties storageProperties;
|
||||
|
||||
public UploadFileVo saveTempFile(MultipartFile file) throws IOException {
|
||||
public UploadFileVo saveTempFile(MultipartFile file, String relativePath) throws IOException {
|
||||
File tempDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||
File parentDir = tempDir.getParentFile();
|
||||
if (parentDir != null) {
|
||||
@@ -46,6 +46,7 @@ public class LocalFileStorageService {
|
||||
vo.setOriginalFilename(file.getOriginalFilename());
|
||||
vo.setLocalPath(target.getAbsolutePath());
|
||||
vo.setSize(file.getSize());
|
||||
vo.setRelativePath(relativePath);
|
||||
return vo;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ public class SplitRunController {
|
||||
- splitMode=rows_per_file 时按固定条数切分;
|
||||
- splitMode=parts 时按总条数尽量均分;
|
||||
- 输出文件命名遵循 原文件名_split_序号.xlsx;
|
||||
- 返回结果中 rowCount 表示该结果文件包含的数据行数。
|
||||
- 本次拆分任务产生的所有 xlsx 会统一打成一个 zip 上传;
|
||||
- 返回结果中 rowCount 表示该压缩包包含的数据总行数,entries 表示压缩包内文件摘要。
|
||||
""")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回拆分结果统计", content = @Content(schema = @Schema(implementation = SplitRunVo.class))),
|
||||
|
||||
@@ -30,4 +30,7 @@ public class SplitRunRequest {
|
||||
|
||||
@Schema(description = "按份数拆分时的份数")
|
||||
private Integer parts;
|
||||
|
||||
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
|
||||
private String archiveName;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public class SplitSourceFileDto {
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String originalFilename;
|
||||
|
||||
@Schema(description = "相对上传目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.split.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "拆分压缩包内文件项")
|
||||
public class SplitArchiveEntryVo {
|
||||
|
||||
@Schema(description = "文件名")
|
||||
private String filename;
|
||||
|
||||
@Schema(description = "该文件包含的数据行数")
|
||||
private Integer rowCount;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.nanri.aiimage.modules.split.model.vo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数据拆分结果项")
|
||||
public class SplitResultItemVo {
|
||||
@@ -22,9 +24,15 @@ public class SplitResultItemVo {
|
||||
@Schema(description = "错误信息")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "结果行数")
|
||||
@Schema(description = "结果总行数")
|
||||
private Integer rowCount;
|
||||
|
||||
@Schema(description = "下载地址")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "压缩包内文件数量")
|
||||
private Integer entryCount;
|
||||
|
||||
@Schema(description = "压缩包内文件列表")
|
||||
private List<SplitArchiveEntryVo> entries;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package com.nanri.aiimage.modules.split.service;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest;
|
||||
import com.nanri.aiimage.modules.split.model.dto.SplitSourceFileDto;
|
||||
import com.nanri.aiimage.modules.split.model.vo.SplitArchiveEntryVo;
|
||||
import com.nanri.aiimage.modules.split.model.vo.SplitResultItemVo;
|
||||
import com.nanri.aiimage.modules.split.model.vo.SplitRunVo;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
@@ -20,6 +22,7 @@ import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -28,18 +31,28 @@ import org.springframework.stereotype.Service;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SplitRunService {
|
||||
|
||||
private static final String MODULE_TYPE = "SPLIT";
|
||||
private static final String ZIP_CONTENT_TYPE = "application/zip";
|
||||
private static final String SPLIT_MODE_ROWS_PER_FILE = "rows_per_file";
|
||||
private static final String SPLIT_MODE_PARTS = "parts";
|
||||
private static final String HEADER_SUFFIX_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf";
|
||||
private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408";
|
||||
private static final DateTimeFormatter ZIP_NAME_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final StorageProperties storageProperties;
|
||||
@@ -49,8 +62,8 @@ public class SplitRunService {
|
||||
validateRequest(request);
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setTaskNo("SPLIT-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType("SPLIT");
|
||||
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setTaskMode("IMMEDIATE");
|
||||
task.setStatus("SUCCESS");
|
||||
task.setSourceFileCount(request.getFiles().size());
|
||||
@@ -61,56 +74,42 @@ public class SplitRunService {
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
boolean folderMode = request.getArchiveName() != null && !request.getArchiveName().isBlank();
|
||||
List<SplitResultItemVo> items = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
List<TaskSplitChunkResult> allChunkResults = new ArrayList<>();
|
||||
List<String> successSourceNames = new ArrayList<>();
|
||||
|
||||
for (SplitSourceFileDto sourceFile : request.getFiles()) {
|
||||
try {
|
||||
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||
if (inputFile == null || !inputFile.exists()) {
|
||||
throw new BusinessException("上传文件不存在,请重新上传");
|
||||
throw new BusinessException("Uploaded source file does not exist, please upload again.");
|
||||
}
|
||||
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||
|
||||
String inputName = sourceFile.getOriginalFilename() == null
|
||||
? inputFile.getName()
|
||||
: sourceFile.getOriginalFilename();
|
||||
List<SplitChunkResult> chunkResults = splitExcelByLegacyRules(inputFile, inputName, request);
|
||||
for (SplitChunkResult chunkResult : chunkResults) {
|
||||
SplitResultItemVo item = new SplitResultItemVo();
|
||||
item.setSourceFilename(inputName);
|
||||
item.setOutputFilename(chunkResult.outputFile().getName());
|
||||
item.setSuccess(true);
|
||||
item.setRowCount(chunkResult.rowCount());
|
||||
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(chunkResult.outputFile(), "SPLIT");
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("SPLIT");
|
||||
resultEntity.setSourceFilename(inputName);
|
||||
resultEntity.setResultFilename(chunkResult.outputFile().getName());
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(chunkResult.outputFile().length());
|
||||
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
resultEntity.setRowCount(chunkResult.rowCount());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
item.setResultId(resultEntity.getId());
|
||||
items.add(item);
|
||||
allChunkResults.add(new TaskSplitChunkResult(sourceFile.getRelativePath(), inputName, chunkResult.outputFile(), chunkResult.rowCount()));
|
||||
}
|
||||
successSourceNames.add(inputName);
|
||||
successCount++;
|
||||
} catch (Exception ex) {
|
||||
SplitResultItemVo item = new SplitResultItemVo();
|
||||
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
item.setSuccess(false);
|
||||
item.setError(ex.getMessage());
|
||||
item.setEntries(new ArrayList<>());
|
||||
item.setEntryCount(0);
|
||||
items.add(item);
|
||||
failedCount++;
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType("SPLIT");
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
resultEntity.setSuccess(0);
|
||||
resultEntity.setErrorMessage(ex.getMessage());
|
||||
@@ -119,6 +118,38 @@ public class SplitRunService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!allChunkResults.isEmpty()) {
|
||||
File zipFile = packageTaskSplitResultsAsZip(request.getArchiveName(), successSourceNames, allChunkResults);
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
|
||||
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||
|
||||
SplitResultItemVo item = new SplitResultItemVo();
|
||||
item.setSourceFilename(folderMode && request.getArchiveName() != null && !request.getArchiveName().isBlank()
|
||||
? request.getArchiveName()
|
||||
: successSourceNames.size() == 1 ? successSourceNames.get(0) : "Current split task");
|
||||
item.setOutputFilename(zipFile.getName());
|
||||
item.setSuccess(true);
|
||||
item.setRowCount(allChunkResults.stream().mapToInt(TaskSplitChunkResult::rowCount).sum());
|
||||
item.setDownloadUrl(downloadUrl);
|
||||
item.setEntryCount(allChunkResults.size());
|
||||
item.setEntries(allChunkResults.stream().map(this::toArchiveEntry).toList());
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(item.getSourceFilename());
|
||||
resultEntity.setResultFilename(zipFile.getName());
|
||||
resultEntity.setResultFileUrl(downloadUrl);
|
||||
resultEntity.setResultFileSize(zipFile.length());
|
||||
resultEntity.setResultContentType(ZIP_CONTENT_TYPE);
|
||||
resultEntity.setRowCount(item.getRowCount());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
item.setResultId(resultEntity.getId());
|
||||
items.add(0, item);
|
||||
}
|
||||
|
||||
task.setSuccessFileCount(successCount);
|
||||
task.setFailedFileCount(failedCount);
|
||||
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||
@@ -135,19 +166,21 @@ public class SplitRunService {
|
||||
|
||||
public List<SplitResultItemVo> listHistory() {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, "SPLIT")
|
||||
.eq(FileResultEntity::getSuccess, 1)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"))
|
||||
.stream()
|
||||
.map(entity -> {
|
||||
SplitResultItemVo vo = new SplitResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||
vo.setSourceFilename(entity.getSourceFilename());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null);
|
||||
vo.setRowCount(entity.getRowCount());
|
||||
vo.setSuccess(true);
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
vo.setEntryCount(0);
|
||||
vo.setEntries(new ArrayList<>());
|
||||
return vo;
|
||||
})
|
||||
.toList();
|
||||
@@ -155,55 +188,45 @@ public class SplitRunService {
|
||||
|
||||
public void deleteHistory(Long resultId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !"SPLIT".equals(entity.getModuleType())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType())) {
|
||||
throw new BusinessException("Split result record does not exist.");
|
||||
}
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
|
||||
public Resource getResultFile(Long resultId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || entity.getResultFileUrl() == null || !"SPLIT".equals(entity.getModuleType())) {
|
||||
throw new BusinessException("结果文件不存在");
|
||||
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType())) {
|
||||
throw new BusinessException("Split result file does not exist.");
|
||||
}
|
||||
|
||||
File file = new File(entity.getResultFileUrl());
|
||||
if (!file.exists()) {
|
||||
throw new BusinessException("结果文件不存在");
|
||||
throw new BusinessException("Split result file does not exist.");
|
||||
}
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
private List<SplitChunkResult> splitExcelByLegacyRules(File inputFile, String originalFilename, SplitRunRequest request) throws Exception {
|
||||
private List<SplitChunkResult> splitExcelByLegacyRules(
|
||||
File inputFile,
|
||||
String originalFilename,
|
||||
SplitRunRequest request
|
||||
) throws Exception {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
|
||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
||||
Workbook workbook = WorkbookFactory.create(fis)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row headerRow = sheet.getRow(0);
|
||||
if (headerRow == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
throw new BusinessException("Excel header row is empty.");
|
||||
}
|
||||
|
||||
Map<String, Integer> headerMap = new HashMap<>();
|
||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||
Cell cell = headerRow.getCell(i);
|
||||
String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell));
|
||||
if (value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty()
|
||||
? value
|
||||
: value.split("idASIN国家状态价格变体数量", 2)[0].trim();
|
||||
if (headerMap.containsKey(value)) {
|
||||
continue;
|
||||
}
|
||||
headerMap.put(value, i);
|
||||
if ("缩略图地址8".equals(value)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> missing = request.getSelectedColumns().stream().filter(column -> !headerMap.containsKey(column)).toList();
|
||||
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
|
||||
List<String> missing = request.getSelectedColumns().stream()
|
||||
.filter(column -> !headerMap.containsKey(column))
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
|
||||
}
|
||||
|
||||
List<List<String>> rowsData = new ArrayList<>();
|
||||
@@ -215,39 +238,23 @@ public class SplitRunService {
|
||||
List<String> projected = new ArrayList<>();
|
||||
for (String column : request.getSelectedColumns()) {
|
||||
Integer sourceIndex = headerMap.get(column);
|
||||
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||
String value = sourceIndex == null
|
||||
? ""
|
||||
: normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||
projected.add(value);
|
||||
}
|
||||
rowsData.add(projected);
|
||||
}
|
||||
|
||||
int totalRows = rowsData.size();
|
||||
if (totalRows == 0) {
|
||||
throw new BusinessException("没有可拆分的数据行");
|
||||
}
|
||||
|
||||
List<List<List<String>>> chunks = new ArrayList<>();
|
||||
if ("rows_per_file".equals(request.getSplitMode())) {
|
||||
int chunkSize = request.getRowsPerFile();
|
||||
for (int i = 0; i < totalRows; i += chunkSize) {
|
||||
chunks.add(rowsData.subList(i, Math.min(i + chunkSize, totalRows)));
|
||||
}
|
||||
} else {
|
||||
int actualParts = Math.min(request.getParts(), totalRows);
|
||||
int base = totalRows / actualParts;
|
||||
int remainder = totalRows % actualParts;
|
||||
int start = 0;
|
||||
for (int i = 0; i < actualParts; i++) {
|
||||
int size = base + (i < remainder ? 1 : 0);
|
||||
int end = start + size;
|
||||
chunks.add(rowsData.subList(start, end));
|
||||
start = end;
|
||||
}
|
||||
if (rowsData.isEmpty()) {
|
||||
throw new BusinessException("No data rows available for splitting.");
|
||||
}
|
||||
|
||||
List<List<List<String>>> chunks = buildChunks(rowsData, request);
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result"));
|
||||
String sourceStem = FileUtil.mainName(originalFilename);
|
||||
List<SplitChunkResult> results = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
File outputFile = buildNamedOutputFile(outputDir, sourceStem + "_split_" + (i + 1) + ".xlsx");
|
||||
try (XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
|
||||
@@ -269,19 +276,63 @@ public class SplitRunService {
|
||||
}
|
||||
results.add(new SplitChunkResult(outputFile, chunks.get(i).size()));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Integer> buildHeaderMap(Row headerRow, DataFormatter formatter) {
|
||||
Map<String, Integer> headerMap = new HashMap<>();
|
||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||
Cell cell = headerRow.getCell(i);
|
||||
String value = normalizeHeaderName(cell == null ? null : formatter.formatCellValue(cell));
|
||||
if (value.isBlank() || headerMap.containsKey(value)) {
|
||||
continue;
|
||||
}
|
||||
headerMap.put(value, i);
|
||||
if (HEADER_STOP_COLUMN.equals(value)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
private List<List<List<String>>> buildChunks(List<List<String>> rowsData, SplitRunRequest request) {
|
||||
int totalRows = rowsData.size();
|
||||
List<List<List<String>>> chunks = new ArrayList<>();
|
||||
|
||||
if (SPLIT_MODE_ROWS_PER_FILE.equals(request.getSplitMode())) {
|
||||
int chunkSize = request.getRowsPerFile();
|
||||
for (int i = 0; i < totalRows; i += chunkSize) {
|
||||
chunks.add(rowsData.subList(i, Math.min(i + chunkSize, totalRows)));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
int actualParts = Math.min(request.getParts(), totalRows);
|
||||
int base = totalRows / actualParts;
|
||||
int remainder = totalRows % actualParts;
|
||||
int start = 0;
|
||||
for (int i = 0; i < actualParts; i++) {
|
||||
int size = base + (i < remainder ? 1 : 0);
|
||||
int end = start + size;
|
||||
chunks.add(rowsData.subList(start, end));
|
||||
start = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private void validateRequest(SplitRunRequest request) {
|
||||
if (!List.of("rows_per_file", "parts").contains(request.getSplitMode())) {
|
||||
throw new BusinessException("拆分模式不合法");
|
||||
if (!List.of(SPLIT_MODE_ROWS_PER_FILE, SPLIT_MODE_PARTS).contains(request.getSplitMode())) {
|
||||
throw new BusinessException("Invalid split mode.");
|
||||
}
|
||||
if ("rows_per_file".equals(request.getSplitMode()) && (request.getRowsPerFile() == null || request.getRowsPerFile() <= 0)) {
|
||||
throw new BusinessException("每份条数不合法");
|
||||
if (SPLIT_MODE_ROWS_PER_FILE.equals(request.getSplitMode())
|
||||
&& (request.getRowsPerFile() == null || request.getRowsPerFile() <= 0)) {
|
||||
throw new BusinessException("Invalid rowsPerFile value.");
|
||||
}
|
||||
if ("parts".equals(request.getSplitMode()) && (request.getParts() == null || request.getParts() <= 0)) {
|
||||
throw new BusinessException("拆分份数不合法");
|
||||
if (SPLIT_MODE_PARTS.equals(request.getSplitMode())
|
||||
&& (request.getParts() == null || request.getParts() <= 0)) {
|
||||
throw new BusinessException("Invalid parts value.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +341,11 @@ public class SplitRunService {
|
||||
if (!baseDir.exists()) {
|
||||
return null;
|
||||
}
|
||||
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
||||
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
||||
List<File> matchedFiles = FileUtil.loopFiles(
|
||||
baseDir,
|
||||
pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)
|
||||
);
|
||||
return matchedFiles.isEmpty() ? null : matchedFiles.get(0);
|
||||
}
|
||||
|
||||
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||
@@ -299,6 +353,7 @@ public class SplitRunService {
|
||||
if (!candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
String mainName = FileUtil.mainName(filename);
|
||||
String extName = FileUtil.extName(filename);
|
||||
int index = 2;
|
||||
@@ -314,6 +369,61 @@ public class SplitRunService {
|
||||
}
|
||||
}
|
||||
|
||||
private File packageTaskSplitResultsAsZip(String archiveName, List<String> successSourceNames, List<TaskSplitChunkResult> chunkResults) {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result"));
|
||||
String zipFilename = archiveName != null && !archiveName.isBlank()
|
||||
? archiveName + ".zip"
|
||||
: successSourceNames.size() == 1
|
||||
? FileUtil.mainName(successSourceNames.getFirst()) + ".zip"
|
||||
: LocalDateTime.now().format(ZIP_NAME_DATE_FORMATTER) + ".zip";
|
||||
File zipFile = buildNamedOutputFile(outputDir, zipFilename);
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
|
||||
for (TaskSplitChunkResult chunkResult : chunkResults) {
|
||||
String zipEntryName = buildFolderZipEntry(chunkResult.relativePath(), chunkResult.sourceFilename(), chunkResult.outputFile().getName(), true);
|
||||
ZipEntry entry = new ZipEntry(zipEntryName);
|
||||
zos.putNextEntry(entry);
|
||||
Files.copy(chunkResult.outputFile().toPath(), zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("拆分结果打包失败:" + ex.getMessage());
|
||||
}
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
private String buildFolderZipEntry(String relativePath, String inputName, String outputFilename, boolean includeSourceStemFolder) {
|
||||
String normalizedRelativePath = relativePath == null ? "" : relativePath.replace('\\', '/');
|
||||
String relativeDir = normalizedRelativePath.contains("/")
|
||||
? normalizedRelativePath.substring(0, normalizedRelativePath.lastIndexOf('/'))
|
||||
: "";
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (!relativeDir.isBlank()) {
|
||||
parts.add(relativeDir);
|
||||
}
|
||||
if (includeSourceStemFolder) {
|
||||
parts.add(FileUtil.mainName(inputName));
|
||||
}
|
||||
parts.add(outputFilename);
|
||||
return String.join("/", parts);
|
||||
}
|
||||
|
||||
private SplitArchiveEntryVo toArchiveEntry(TaskSplitChunkResult chunkResult) {
|
||||
SplitArchiveEntryVo vo = new SplitArchiveEntryVo();
|
||||
vo.setFilename(chunkResult.outputFile().getName());
|
||||
vo.setRowCount(chunkResult.rowCount());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String normalizeHeaderName(String value) {
|
||||
String normalized = normalizeCellText(value);
|
||||
if (normalized.isBlank()) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
String prefix = normalized.split(HEADER_SUFFIX_MARKER, 2)[0].trim();
|
||||
return prefix.isEmpty() ? normalized : prefix;
|
||||
}
|
||||
|
||||
private String normalizeCellText(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
@@ -330,4 +440,7 @@ public class SplitRunService {
|
||||
|
||||
private record SplitChunkResult(File outputFile, int rowCount) {
|
||||
}
|
||||
|
||||
private record TaskSplitChunkResult(String relativePath, String sourceFilename, File outputFile, int rowCount) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_dedupe_total_data (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
data_value VARCHAR(128) NOT NULL COMMENT '去重总数据值',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_data_value (data_value),
|
||||
KEY idx_created_at (created_at)
|
||||
) COMMENT='数据去重总数据表';
|
||||
@@ -0,0 +1,5 @@
|
||||
UPDATE biz_convert_template
|
||||
SET template_name = '五国模板',
|
||||
output_filename = '英国.txt、法国.txt、德国.txt、西班牙.txt、意大利.txt',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE template_code = 'uk_offer';
|
||||
Reference in New Issue
Block a user