后端优化,增加新需求

This commit is contained in:
super
2026-07-21 19:53:47 +08:00
parent ba48bbc6af
commit bca1335dc6
16 changed files with 529 additions and 61 deletions

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.dedupe.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
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.DedupeTotalDataImportProgressVo;
@@ -17,6 +18,10 @@ 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.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -28,12 +33,18 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/dedupe-total-data")
@Tag(name = "数据去重总数据", description = "维护数据去重模块的总数据列表,支持增删改查。")
public class DedupeTotalDataController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final DedupeTotalDataService dedupeTotalDataService;
@GetMapping
@@ -50,6 +61,24 @@ public class DedupeTotalDataController {
return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword, username, operatorId));
}
@GetMapping("/export")
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据。")
public ResponseEntity<byte[]> export(
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
@Parameter(description = "开始日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "结束日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
byte[] bytes = dedupeTotalDataService.export(username, startDate, endDate, operatorId);
String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(bytes.length)
.body(bytes);
}
@PostMapping
@Operation(summary = "新增总数据", description = "新增一条数据去重总数据。")
@ApiResponses({

View File

@@ -21,6 +21,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.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
@@ -28,11 +29,16 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.HttpStatus;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.server.ResponseStatusException;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
@@ -48,6 +54,8 @@ import java.util.concurrent.ConcurrentHashMap;
public class DedupeTotalDataService {
private static final int COMPARE_BATCH_SIZE = 5000;
private static final long COMPLETED_PROGRESS_RETENTION_MILLIS = 60 * 60 * 1000L;
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final DedupeTotalDataMapper dedupeTotalDataMapper;
private final AdminUserMapper adminUserMapper;
@@ -57,6 +65,8 @@ public class DedupeTotalDataService {
private final Map<String, DedupeTotalDataImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
private final Map<String, Long> importOwnerMap = new ConcurrentHashMap<>();
private final Map<String, Long> deleteImportOwnerMap = new ConcurrentHashMap<>();
private final Map<String, Long> importCompletedAtMap = new ConcurrentHashMap<>();
private final Map<String, Long> deleteImportCompletedAtMap = new ConcurrentHashMap<>();
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
@@ -70,16 +80,10 @@ public class DedupeTotalDataService {
String safeKeyword = keyword == null ? "" : keyword.trim();
String safeUsername = username == null ? "" : username.trim();
AccessScope scope = resolveAccessScope(operatorId);
Set<Long> usernameUserIds = safeUsername.isEmpty()
? Set.of()
: findUserIdsByUsername(safeUsername, scope);
if (!safeUsername.isEmpty() && usernameUserIds.isEmpty()) {
return emptyPage(safePage, safePageSize);
}
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
.like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword)
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())
.in(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUserId, usernameUserIds)
.like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername)
.orderByDesc(DedupeTotalDataEntity::getId);
Long total = dedupeTotalDataMapper.selectCount(query);
List<DedupeTotalDataItemVo> items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
@@ -94,6 +98,57 @@ public class DedupeTotalDataService {
return vo;
}
public byte[] export(String username, LocalDate startDate, LocalDate endDate, Long operatorId) {
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new BusinessException("开始日期不能晚于结束日期");
}
String safeUsername = username == null ? "" : username.trim();
AccessScope scope = resolveAccessScope(operatorId);
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())
.like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername)
.ge(startDate != null, DedupeTotalDataEntity::getCreatedAt,
startDate == null ? null : startDate.atStartOfDay())
.lt(endDate != null, DedupeTotalDataEntity::getCreatedAt,
endDate == null ? null : endDate.plusDays(1).atStartOfDay())
.orderByDesc(DedupeTotalDataEntity::getId);
List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(query);
return buildExportWorkbook(rows);
}
private byte[] buildExportWorkbook(List<DedupeTotalDataEntity> rows) {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("DedupeTotalData");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("ID");
header.createCell(1).setCellValue("ASIN值");
header.createCell(2).setCellValue("用户名");
header.createCell(3).setCellValue("创建时间");
for (int index = 0; index < rows.size(); index++) {
DedupeTotalDataEntity entity = rows.get(index);
Row row = sheet.createRow(index + 1);
row.createCell(0).setCellValue(entity.getId() == null ? "" : String.valueOf(entity.getId()));
row.createCell(1).setCellValue(entity.getDataValue() == null ? "" : entity.getDataValue());
row.createCell(2).setCellValue(entity.getUploaderUsername() == null ? "" : entity.getUploaderUsername());
row.createCell(3).setCellValue(formatExportTime(entity.getCreatedAt()));
}
sheet.setColumnWidth(0, 3600);
sheet.setColumnWidth(1, 5200);
sheet.setColumnWidth(2, 5200);
sheet.setColumnWidth(3, 5600);
workbook.write(outputStream);
workbook.dispose();
return outputStream.toByteArray();
} catch (Exception ex) {
throw new BusinessException("导出数据去重总数据失败");
}
}
private String formatExportTime(LocalDateTime value) {
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
}
@Transactional
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) {
AdminUserEntity uploader = getOperator(operatorId);
@@ -129,12 +184,19 @@ public class DedupeTotalDataService {
Set<String> existingValues = new HashSet<>();
for (int start = 0; start < normalizedValues.size(); start += COMPARE_BATCH_SIZE) {
int end = Math.min(start + COMPARE_BATCH_SIZE, normalizedValues.size());
existingValues.addAll(dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end)));
List<String> batch = dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end));
if (batch != null) {
batch.stream()
.map(this::normalizeComparableValueOrBlank)
.filter(value -> !value.isEmpty())
.forEach(existingValues::add);
}
}
return existingValues;
}
public DedupeTotalDataImportStartVo startImport(MultipartFile file, Long operatorId) {
cleanupExpiredProgress();
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
}
@@ -167,6 +229,7 @@ public class DedupeTotalDataService {
}
public DedupeTotalDataImportProgressVo getImportProgress(String importId, Long operatorId) {
cleanupExpiredProgress();
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
Long ownerId = importOwnerMap.get(importId);
if (progress == null || ownerId == null) {
@@ -177,6 +240,7 @@ public class DedupeTotalDataService {
}
public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file, Long operatorId) {
cleanupExpiredProgress();
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
}
@@ -208,6 +272,7 @@ public class DedupeTotalDataService {
}
public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId, Long operatorId) {
cleanupExpiredProgress();
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
Long ownerId = deleteImportOwnerMap.get(importId);
if (progress == null || ownerId == null) {
@@ -237,6 +302,7 @@ public class DedupeTotalDataService {
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败");
} finally {
deleteQuietly(tempFile);
deleteImportCompletedAtMap.put(importId, System.currentTimeMillis());
}
}
@@ -261,6 +327,7 @@ public class DedupeTotalDataService {
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败");
} finally {
deleteQuietly(tempFile);
importCompletedAtMap.put(importId, System.currentTimeMillis());
}
}
@@ -407,17 +474,22 @@ public class DedupeTotalDataService {
continue;
}
final String pendingDataValue = dataValue;
Boolean inserted = newRequiresNewTemplate().execute(status -> {
if (existsDataValue(pendingDataValue)) {
return false;
}
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
entity.setDataValue(pendingDataValue);
entity.setUploaderUserId(uploaderUserId);
entity.setUploaderUsername(uploaderUsername);
dedupeTotalDataMapper.insert(entity);
return true;
});
Boolean inserted;
try {
inserted = newRequiresNewTemplate().execute(status -> {
if (existsDataValue(pendingDataValue)) {
return false;
}
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
entity.setDataValue(pendingDataValue);
entity.setUploaderUserId(uploaderUserId);
entity.setUploaderUsername(uploaderUsername);
dedupeTotalDataMapper.insert(entity);
return true;
});
} catch (DuplicateKeyException ignored) {
inserted = false;
}
if (Boolean.TRUE.equals(inserted)) {
insertedCount++;
} else {
@@ -590,7 +662,7 @@ public class DedupeTotalDataService {
}
public String normalizeComparableValueOrBlank(String value) {
return normalizeExcelText(value);
return normalizeExcelText(value).toUpperCase(Locale.ROOT);
}
private void ensureUnique(String dataValue, Long excludeId) {
@@ -649,31 +721,29 @@ public class DedupeTotalDataService {
|| (role.isEmpty() && Integer.valueOf(1).equals(user.getIsAdmin()) && user.getCreatedById() == null);
}
private Set<Long> findUserIdsByUsername(String username, AccessScope scope) {
Set<Long> matched = adminUserMapper.selectIdsByUsernameLike(username)
.stream()
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toSet());
if (scope.allUsers()) {
return matched;
}
matched.retainAll(scope.userIds());
return matched;
}
private void ensureUploaderAccess(Long uploaderUserId, AccessScope scope) {
if (!scope.allUsers() && (uploaderUserId == null || !scope.userIds().contains(uploaderUserId))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权操作该总数据");
}
}
private DedupeTotalDataPageVo emptyPage(long page, long pageSize) {
DedupeTotalDataPageVo vo = new DedupeTotalDataPageVo();
vo.setItems(List.of());
vo.setTotal(0L);
vo.setPage(page);
vo.setPageSize(pageSize);
return vo;
private void cleanupExpiredProgress() {
long cutoff = System.currentTimeMillis() - COMPLETED_PROGRESS_RETENTION_MILLIS;
cleanupExpiredProgressEntries(importCompletedAtMap, importProgressMap, importOwnerMap, cutoff);
cleanupExpiredProgressEntries(deleteImportCompletedAtMap, deleteImportProgressMap, deleteImportOwnerMap, cutoff);
}
private void cleanupExpiredProgressEntries(
Map<String, Long> completedAtMap,
Map<String, DedupeTotalDataImportProgressVo> progressMap,
Map<String, Long> ownerMap,
long cutoff) {
completedAtMap.forEach((id, completedAt) -> {
if (completedAt != null && completedAt < cutoff && completedAtMap.remove(id, completedAt)) {
progressMap.remove(id);
ownerMap.remove(id);
}
});
}
private String normalizeExcelText(String value) {

View File

@@ -3,14 +3,7 @@ package com.nanri.aiimage.modules.permission.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface AdminUserMapper extends BaseMapper<AdminUserEntity> {
@Select("SELECT id FROM users WHERE username LIKE CONCAT('%', #{username}, '%') ORDER BY id ASC")
List<Long> selectIdsByUsernameLike(@Param("username") String username);
}

View File

@@ -9,6 +9,10 @@ import lombok.Data;
@Schema(description = "店铺密钥新增请求")
public class ShopKeyCreateRequest {
@Size(max = 128, message = "备注名长度不能超过128个字符")
@Schema(description = "备注名")
private String remarkName;
@NotBlank(message = "紫鸟账号名称不能为空")
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -9,6 +9,10 @@ import lombok.Data;
@Schema(description = "店铺密钥更新请求")
public class ShopKeyUpdateRequest {
@Size(max = 128, message = "备注名长度不能超过128个字符")
@Schema(description = "备注名")
private String remarkName;
@NotBlank(message = "紫鸟账号名称不能为空")
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -13,6 +13,7 @@ public class ShopKeyEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String remarkName;
private String ziniaoAccountName;
private String ziniaoToken;
private LocalDateTime createdAt;

View File

@@ -12,6 +12,9 @@ public class ShopKeyItemVo {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "备注名")
private String remarkName;
@Schema(description = "紫鸟账号名称")
private String ziniaoAccountName;

View File

@@ -47,6 +47,7 @@ public class ShopKeyService {
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
ShopKeyEntity entity = new ShopKeyEntity();
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken);
shopKeyMapper.insert(entity);
@@ -59,6 +60,7 @@ public class ShopKeyService {
ShopKeyEntity entity = getById(id);
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken);
shopKeyMapper.updateById(entity);
@@ -89,9 +91,14 @@ public class ShopKeyService {
return normalized;
}
private String normalizeOptional(String value) {
return value == null ? "" : value.trim();
}
private ShopKeyItemVo toItemVo(ShopKeyEntity entity) {
ShopKeyItemVo vo = new ShopKeyItemVo();
vo.setId(entity.getId());
vo.setRemarkName(entity.getRemarkName());
vo.setZiniaoAccountName(entity.getZiniaoAccountName());
vo.setZiniaoToken(entity.getZiniaoToken());
vo.setCreatedAt(entity.getCreatedAt());

View File

@@ -115,6 +115,7 @@ public class TaskResultFileJobWorker {
private void processInternal(TaskFileJobEntity job) {
long startedAt = System.currentTimeMillis();
boolean finalizeWithdraw = false;
try {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
@@ -162,10 +163,14 @@ public class TaskResultFileJobWorker {
String resultFileUrl = resolveResultFileUrl(job);
taskFileJobService.markSuccess(job, resultFileUrl);
cleanupAfterSuccess(job);
finalizeWithdraw = "WITHDRAW".equals(job.getModuleType());
log.info("[task-file-job] process success jobId={} taskId={} moduleType={} resultId={} elapsedMs={} resultFileUrl={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt, resultFileUrl);
}
if (finalizeWithdraw) {
withdrawTaskService.tryFinalizeTask(job.getTaskId(), false);
}
} catch (Exception ex) {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {

View File

@@ -0,0 +1,2 @@
ALTER TABLE biz_shop_key
ADD COLUMN remark_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '备注名' AFTER id;

View File

@@ -1,8 +1,10 @@
package com.nanri.aiimage.modules.dedupe.service;
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.entity.DedupeTotalDataEntity;
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportProgressVo;
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
@@ -15,15 +17,23 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -42,6 +52,8 @@ class DedupeTotalDataServiceTest {
private ShopManageGroupMapper shopManageGroupMapper;
@Mock
private PlatformTransactionManager transactionManager;
@Mock
private TransactionStatus transactionStatus;
@InjectMocks
private DedupeTotalDataService service;
@@ -65,13 +77,14 @@ class DedupeTotalDataServiceTest {
});
DedupeTotalDataCreateRequest request = new DedupeTotalDataCreateRequest();
request.setDataValue(" B012345678 ");
request.setDataValue(" b012345678 ");
DedupeTotalDataItemVo item = service.create(request, 23L);
ArgumentCaptor<DedupeTotalDataEntity> captor = ArgumentCaptor.forClass(DedupeTotalDataEntity.class);
verify(dedupeTotalDataMapper).insert(captor.capture());
assertEquals(23L, captor.getValue().getUploaderUserId());
assertEquals("member-a", captor.getValue().getUploaderUsername());
assertEquals("B012345678", captor.getValue().getDataValue());
assertEquals("member-a", item.getUsername());
}
@@ -125,31 +138,106 @@ class DedupeTotalDataServiceTest {
}
@Test
void usernameSearchCannotExpandLeaderScope() {
void concurrentDuplicateDuringImportIsSkipped() throws Exception {
when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a"));
when(dedupeTotalDataMapper.selectOne(any())).thenReturn(null);
when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
when(dedupeTotalDataMapper.insert(any(DedupeTotalDataEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
var result = service.importFromExcel(asinWorkbook("b012345678"), 23L);
assertEquals(0, result.getInsertedCount());
assertEquals(1, result.getSkippedCount());
verify(transactionManager).rollback(transactionStatus);
}
@Test
void usernameSearchKeepsLeaderScope() {
when(adminUserMapper.selectById(10L)).thenReturn(user(10L, "admin", "leader"));
when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L));
when(adminUserMapper.selectIdsByUsernameLike("other")).thenReturn(List.of(99L));
when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L);
when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of());
DedupeTotalDataPageVo page = service.page(1, 15, "", "other", 10L);
assertEquals(0L, page.getTotal());
assertTrue(page.getItems().isEmpty());
verify(dedupeTotalDataMapper, never()).selectCount(any());
verify(dedupeTotalDataMapper, never()).selectList(any());
verify(shopManageGroupMapper).selectManagedMemberUserIds(10L);
verify(dedupeTotalDataMapper).selectCount(any());
verify(dedupeTotalDataMapper).selectList(any());
}
@Test
void comparableValueLookupRemainsGlobal() {
when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678")))
.thenReturn(List.of("B012345678"));
.thenReturn(List.of("b012345678"));
Set<String> values = service.findExistingComparableValues(List.of(" B012345678 "));
Set<String> values = service.findExistingComparableValues(List.of(" b012345678 "));
assertEquals(Set.of("B012345678"), values);
verify(adminUserMapper, never()).selectById(any(Long.class));
verify(shopManageGroupMapper, never()).selectManagedMemberUserIds(any(Long.class));
}
@Test
void exportUsesScopedUsernameAndProducesWorkbook() throws Exception {
when(adminUserMapper.selectById(10L)).thenReturn(user(10L, "admin", "leader"));
when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L));
DedupeTotalDataEntity entity = data(91L, 23L);
entity.setUploaderUsername("member-a");
entity.setCreatedAt(LocalDateTime.of(2026, 7, 20, 12, 30));
when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of(entity));
byte[] bytes = service.export(
"member",
LocalDate.of(2026, 7, 1),
LocalDate.of(2026, 7, 20),
10L);
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
var sheet = workbook.getSheetAt(0);
assertEquals("ASIN值", sheet.getRow(0).getCell(1).getStringCellValue());
assertEquals("B012345678", sheet.getRow(1).getCell(1).getStringCellValue());
assertEquals("member-a", sheet.getRow(1).getCell(2).getStringCellValue());
assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(3).getStringCellValue());
}
verify(shopManageGroupMapper).selectManagedMemberUserIds(10L);
verify(dedupeTotalDataMapper).selectList(any());
}
@Test
void exportRejectsReversedDateRange() {
assertThrows(BusinessException.class, () -> service.export(
"",
LocalDate.of(2026, 7, 20),
LocalDate.of(2026, 7, 1),
10L));
verify(dedupeTotalDataMapper, never()).selectList(any());
}
@Test
@SuppressWarnings("unchecked")
void expiredCompletedProgressIsRemovedOnNextLookup() {
Map<String, DedupeTotalDataImportProgressVo> progressMap =
(Map<String, DedupeTotalDataImportProgressVo>) ReflectionTestUtils.getField(service, "importProgressMap");
Map<String, Long> ownerMap = (Map<String, Long>) ReflectionTestUtils.getField(service, "importOwnerMap");
Map<String, Long> completedAtMap =
(Map<String, Long>) ReflectionTestUtils.getField(service, "importCompletedAtMap");
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
progress.setStatus("success");
progressMap.put("expired", progress);
ownerMap.put("expired", 23L);
completedAtMap.put("expired", System.currentTimeMillis() - (2 * 60 * 60 * 1000L));
assertThrows(BusinessException.class, () -> service.getImportProgress("expired", 23L));
assertFalse(progressMap.containsKey("expired"));
assertFalse(ownerMap.containsKey("expired"));
assertFalse(completedAtMap.containsKey("expired"));
}
private AdminUserEntity user(Long id, String role, String username) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);

View File

@@ -0,0 +1,82 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TaskResultFileJobWorkerTest {
@Mock private TaskFileJobService taskFileJobService;
@Mock private TaskDistributedLockService taskDistributedLockService;
@Mock private TaskResultPayloadService taskResultPayloadService;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskFileJobLocalDispatcher taskFileJobLocalDispatcher;
@Mock private InstanceMetadata instanceMetadata;
@Mock private ShopMatchTaskService shopMatchTaskService;
@Mock private PriceTrackTaskService priceTrackTaskService;
@Mock private ProductRiskTaskService productRiskTaskService;
@Mock private QueryAsinTaskService queryAsinTaskService;
@Mock private WithdrawTaskService withdrawTaskService;
@Mock private PatrolDeleteTaskService patrolDeleteTaskService;
@Mock private AppearancePatentTaskService appearancePatentTaskService;
@Mock private SimilarAsinTaskService similarAsinTaskService;
@Mock private DeleteBrandRunService deleteBrandRunService;
@Mock private BrandTaskService brandTaskService;
@Mock private CollectDataService collectDataService;
@InjectMocks private TaskResultFileJobWorker worker;
@Test
void withdrawFileSuccessFinalizesTaskAfterReleasingLock() {
long jobId = 13640L;
long taskId = 20140L;
long resultId = 22928L;
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(jobId);
job.setTaskId(taskId);
job.setResultId(resultId);
job.setModuleType("WITHDRAW");
job.setScopeKey("withdraw:20140");
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/20140.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS))
.thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
worker.process(job);
verify(taskFileJobService).markSuccess(job, "result/withdraw/20140.xlsx");
verify(taskResultPayloadService).deleteLatest(taskId, "WITHDRAW", "withdraw:20140");
InOrder order = inOrder(lock, withdrawTaskService);
order.verify(lock).close();
order.verify(withdrawTaskService).tryFinalizeTask(taskId, false);
}
}