@@ -70,6 +70,14 @@
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>easyexcel</artifactId>
|
||||
|
||||
@@ -7,6 +7,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "aiimage.task-pressure")
|
||||
public class TaskPressureProperties {
|
||||
private long localTaskEntityCacheMillis = 3000;
|
||||
// 本地文件缓存有效时长,超过该时长视为过期、强制回查 DB,避免陈旧 RUNNING 被复活
|
||||
private long localTaskEntityFileCacheMillis = 60000;
|
||||
private int dbSelectBatchSize = 200;
|
||||
private long scopePayloadFlushIntervalMillis = 15000;
|
||||
private long scopePayloadBufferRetentionHours = 24;
|
||||
|
||||
+22
-5
@@ -42,6 +42,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
@@ -58,6 +59,8 @@ public class DedupeTotalDataController {
|
||||
private static final String DEDUPE_TOTAL_DATA_ROUTE_PATH = "dedupe-total-data";
|
||||
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final String ZIP_CONTENT_TYPE = "application/zip";
|
||||
|
||||
private final DedupeTotalDataService dedupeTotalDataService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
@@ -91,7 +94,7 @@ public class DedupeTotalDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据。")
|
||||
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据;跨自然月时按月生成 XLSX 并打包为 ZIP。")
|
||||
public ResponseEntity<StreamingResponseBody> export(
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
|
||||
@Parameter(description = "开始日期(包含)")
|
||||
@@ -100,16 +103,30 @@ public class DedupeTotalDataController {
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
HttpServletRequest request) {
|
||||
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
||||
throw new BusinessException(400, "invalid export date range");
|
||||
}
|
||||
RequestOperator operator = requireDedupeTotalDataAccess(request);
|
||||
String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
StreamingResponseBody body = outputStream -> dedupeTotalDataService.writeExport(
|
||||
outputStream, username, startDate, endDate, groupId, operator.id());
|
||||
boolean monthlyZip = isCrossMonthRange(startDate, endDate);
|
||||
String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER)
|
||||
+ (monthlyZip ? ".zip" : ".xlsx");
|
||||
StreamingResponseBody body = monthlyZip
|
||||
? outputStream -> dedupeTotalDataService.writeMonthlyZipExport(
|
||||
outputStream, username, startDate, endDate, groupId, operator.id())
|
||||
: outputStream -> dedupeTotalDataService.writeExport(
|
||||
outputStream, username, startDate, endDate, groupId, operator.id());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.contentType(MediaType.parseMediaType(monthlyZip ? ZIP_CONTENT_TYPE : XLSX_CONTENT_TYPE))
|
||||
.body(body);
|
||||
}
|
||||
|
||||
private boolean isCrossMonthRange(LocalDate startDate, LocalDate endDate) {
|
||||
return startDate != null
|
||||
&& endDate != null
|
||||
&& !YearMonth.from(startDate).equals(YearMonth.from(endDate));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增总数据", description = "新增一条数据去重总数据。")
|
||||
@ApiResponses({
|
||||
|
||||
+82
-3
@@ -40,6 +40,7 @@ import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -53,6 +54,9 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -157,12 +161,67 @@ public class DedupeTotalDataService {
|
||||
buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, scope);
|
||||
}
|
||||
|
||||
public void writeMonthlyZipExport(OutputStream outputStream,
|
||||
String username,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Long groupId,
|
||||
Long operatorId) {
|
||||
if (outputStream == null) {
|
||||
throw new BusinessException("export output stream cannot be null");
|
||||
}
|
||||
if (startDate == null || endDate == null || YearMonth.from(startDate).equals(YearMonth.from(endDate))) {
|
||||
writeExport(outputStream, username, startDate, endDate, groupId, operatorId);
|
||||
return;
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
throw new BusinessException("invalid export date range");
|
||||
}
|
||||
String safeUsername = username == null ? "" : username.trim();
|
||||
AccessScope scope = resolveAccessScope(operatorId);
|
||||
boolean groupPrevalidated = groupId != null && groupId > 0;
|
||||
if (groupPrevalidated) {
|
||||
resolveAccessibleGroup(groupId, scope);
|
||||
}
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
|
||||
zipOutputStream.setLevel(Deflater.NO_COMPRESSION);
|
||||
YearMonth currentMonth = YearMonth.from(startDate);
|
||||
YearMonth endMonth = YearMonth.from(endDate);
|
||||
while (!currentMonth.isAfter(endMonth)) {
|
||||
LocalDate monthStart = currentMonth.atDay(1);
|
||||
LocalDate monthEnd = currentMonth.atEndOfMonth();
|
||||
LocalDate entryStartDate = startDate.isAfter(monthStart) ? startDate : monthStart;
|
||||
LocalDate entryEndDate = endDate.isBefore(monthEnd) ? endDate : monthEnd;
|
||||
zipOutputStream.putNextEntry(new ZipEntry(monthlyExportEntryName(currentMonth)));
|
||||
buildExportWorkbook(zipOutputStream, safeUsername, entryStartDate, entryEndDate,
|
||||
groupId, scope, groupPrevalidated);
|
||||
zipOutputStream.closeEntry();
|
||||
currentMonth = currentMonth.plusMonths(1);
|
||||
}
|
||||
zipOutputStream.finish();
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("export dedupe total data failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void buildExportWorkbook(OutputStream outputStream,
|
||||
String username,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Long groupId,
|
||||
AccessScope scope) {
|
||||
buildExportWorkbook(outputStream, username, startDate, endDate, groupId, scope, false);
|
||||
}
|
||||
|
||||
private void buildExportWorkbook(OutputStream outputStream,
|
||||
String username,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Long groupId,
|
||||
AccessScope scope,
|
||||
boolean groupPrevalidated) {
|
||||
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
|
||||
Sheet sheet = workbook.createSheet("DedupeTotalData");
|
||||
Row header = sheet.createRow(0);
|
||||
@@ -175,7 +234,7 @@ public class DedupeTotalDataService {
|
||||
int rowIndex = 1;
|
||||
while (true) {
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> pageQuery = buildExportQuery(
|
||||
username, startDate, endDate, groupId, scope);
|
||||
username, startDate, endDate, groupId, scope, groupPrevalidated);
|
||||
if (lastId != null) {
|
||||
pageQuery.lt(DedupeTotalDataEntity::getId, lastId);
|
||||
}
|
||||
@@ -221,6 +280,15 @@ public class DedupeTotalDataService {
|
||||
LocalDate endDate,
|
||||
Long groupId,
|
||||
AccessScope scope) {
|
||||
return buildExportQuery(username, startDate, endDate, groupId, scope, false);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<DedupeTotalDataEntity> buildExportQuery(String username,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Long groupId,
|
||||
AccessScope scope,
|
||||
boolean groupPrevalidated) {
|
||||
String safeUsername = username == null ? "" : username.trim();
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername)
|
||||
@@ -229,7 +297,7 @@ public class DedupeTotalDataService {
|
||||
.lt(endDate != null, DedupeTotalDataEntity::getCreatedAt,
|
||||
endDate == null ? null : endDate.plusDays(1).atStartOfDay())
|
||||
.orderByDesc(DedupeTotalDataEntity::getId);
|
||||
applyGroupScope(query, scope, groupId);
|
||||
applyGroupScope(query, scope, groupId, groupPrevalidated);
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -237,6 +305,10 @@ public class DedupeTotalDataService {
|
||||
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
|
||||
}
|
||||
|
||||
private String monthlyExportEntryName(YearMonth month) {
|
||||
return "dedupe-total-data-" + month + ".xlsx";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) {
|
||||
AdminUserEntity uploader = getOperator(operatorId);
|
||||
@@ -885,8 +957,15 @@ public class DedupeTotalDataService {
|
||||
|
||||
private void applyGroupScope(LambdaQueryWrapper<DedupeTotalDataEntity> query,
|
||||
AccessScope scope, Long groupId) {
|
||||
applyGroupScope(query, scope, groupId, false);
|
||||
}
|
||||
|
||||
private void applyGroupScope(LambdaQueryWrapper<DedupeTotalDataEntity> query,
|
||||
AccessScope scope, Long groupId, boolean groupPrevalidated) {
|
||||
if (groupId != null && groupId > 0) {
|
||||
resolveAccessibleGroup(groupId, scope);
|
||||
if (!groupPrevalidated) {
|
||||
resolveAccessibleGroup(groupId, scope);
|
||||
}
|
||||
query.eq(DedupeTotalDataEntity::getGroupId, groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
+101
@@ -815,11 +815,112 @@ public class PriceTrackTaskService {
|
||||
private void enqueueResultFileAssembly(FileResultEntity result,
|
||||
String shopKey,
|
||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
applyServerModifyCounts(result.getTaskId(), payload);
|
||||
taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload);
|
||||
markResultFilePending(result, shopKey, payload);
|
||||
taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey);
|
||||
}
|
||||
|
||||
private void applyServerModifyCounts(
|
||||
Long taskId,
|
||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
if (payload == null || payload.getCountries() == null || payload.getCountries().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Integer> baselineByAsin = buildModifyCountBaseline(taskId);
|
||||
for (Map.Entry<String, List<PriceTrackSubmitResultRequest.AsinResult>> entry : payload.getCountries().entrySet()) {
|
||||
String countryCode = normalizeCountryCode(entry.getKey());
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
for (PriceTrackSubmitResultRequest.AsinResult row : entry.getValue()) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String asin = normalizeAsin(row.getAsin());
|
||||
int baseline = baselineByAsin.getOrDefault(buildModifyCountKey(countryCode, asin), 0);
|
||||
row.setModifyCount(String.valueOf(addOneIfChanged(baseline, row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Integer> buildModifyCountBaseline(Long taskId) {
|
||||
Map<String, List<Map<String, String>>> originalRows = loadTaskAsinRowsPayload(taskId);
|
||||
if (originalRows == null || originalRows.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Integer> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<Map<String, String>>> entry : originalRows.entrySet()) {
|
||||
String countryCode = normalizeCountryCode(entry.getKey());
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map<String, String> row : entry.getValue()) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String asin = normalizeAsin(row.get("asin"));
|
||||
if (asin.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
out.putIfAbsent(
|
||||
buildModifyCountKey(countryCode, asin),
|
||||
parseModifyCountBaseline(row.get("modifyCount")));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String buildModifyCountKey(String countryCode, String asin) {
|
||||
return countryCode + "|" + asin;
|
||||
}
|
||||
|
||||
private String normalizeCountryCode(String countryCode) {
|
||||
return countryCode == null ? "" : countryCode.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeAsin(String asin) {
|
||||
return asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private int parseModifyCountBaseline(String value) {
|
||||
String normalized = normalizeCellText(value);
|
||||
if (normalized.isBlank()) {
|
||||
return 0;
|
||||
}
|
||||
int decimalPoint = normalized.indexOf('.');
|
||||
if (decimalPoint >= 0) {
|
||||
for (int i = decimalPoint + 1; i < normalized.length(); i++) {
|
||||
if (normalized.charAt(i) != '0') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
normalized = normalized.substring(0, decimalPoint);
|
||||
}
|
||||
try {
|
||||
long parsed = Long.parseLong(normalized);
|
||||
if (parsed <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) parsed;
|
||||
} catch (NumberFormatException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int addOneIfChanged(int baseline, PriceTrackSubmitResultRequest.AsinResult row) {
|
||||
if (!isPriceUpdateSuccessStatus(row.getPriceChangeStatus())) {
|
||||
return baseline;
|
||||
}
|
||||
return baseline >= Integer.MAX_VALUE ? Integer.MAX_VALUE : baseline + 1;
|
||||
}
|
||||
|
||||
private boolean isPriceUpdateSuccessStatus(String value) {
|
||||
String normalized = normalizeCellText(value);
|
||||
return "\u6539\u4ef7\u6210\u529f".equals(normalized)
|
||||
|| "UPDATED".equalsIgnoreCase(normalized);
|
||||
}
|
||||
|
||||
private void markResultFilePending(FileResultEntity result,
|
||||
String shopKey,
|
||||
PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
|
||||
+34
-1
@@ -618,11 +618,15 @@ public class ShopDataCrawlTaskService {
|
||||
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
|
||||
List<FileResultEntity> taskRows = listTaskRows(taskId);
|
||||
try (DailyLockSet dailyLocks = acquireDailyLocks(task.getUserId(), taskRows)) {
|
||||
ensureDailySyncCompletedBeforeDelete(taskRows);
|
||||
Set<Long> removedResultIds = taskRows.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
DailyDeletionResult dailyResult = prepareDailyForDeletion(removedResultIds);
|
||||
// A task deletion is only a frontend task-record cleanup. The daily
|
||||
// workbook is an independent backend aggregate and must not roll
|
||||
// back when its source task is removed.
|
||||
DailyDeletionResult dailyResult = preserveDailyForTaskDeletion(removedResultIds);
|
||||
registerUploadedObjectRollback(dailyResult.uploadedObjectKeys());
|
||||
List<String> resultFileUrls = new ArrayList<>(dailyResult.obsoleteObjectKeys());
|
||||
resultFileUrls.addAll(taskRows.stream()
|
||||
@@ -642,6 +646,24 @@ public class ShopDataCrawlTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDailySyncCompletedBeforeDelete(List<FileResultEntity> taskRows) {
|
||||
if (taskRows == null || taskRows.isEmpty()) {
|
||||
throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务");
|
||||
}
|
||||
for (FileResultEntity row : taskRows) {
|
||||
if (row == null || !isResultFinished(row)) {
|
||||
throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务");
|
||||
}
|
||||
if (!Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
if (!taskFileJobService.hasSuccessfulAssembleJob(row.getTaskId(), MODULE_TYPE, row.getId())
|
||||
|| dailyFileService.findMembersByResultId(row.getId()).isEmpty()) {
|
||||
throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteHistory(Long resultId, Long userId) {
|
||||
validateUserId(userId);
|
||||
@@ -756,6 +778,17 @@ public class ShopDataCrawlTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
private DailyDeletionResult preserveDailyForTaskDeletion(Set<Long> removedResultIds) {
|
||||
if (removedResultIds == null || removedResultIds.isEmpty()) {
|
||||
return new DailyDeletionResult(List.of(), List.of());
|
||||
}
|
||||
// Keep the already assembled daily object and its row count intact. Only
|
||||
// remove task membership links so the deleted task is not retained as a
|
||||
// frontend history record and the next crawl can append normally.
|
||||
dailyFileService.deleteMembersForResults(removedResultIds);
|
||||
return new DailyDeletionResult(List.of(), List.of());
|
||||
}
|
||||
|
||||
private List<DailyMemberData> loadDailyMemberData(ShopDataCrawlDailyFileEntity dailyFile,
|
||||
Set<Long> removedResultIds) {
|
||||
List<ShopDataCrawlDailyMemberEntity> memberRows = dailyFileService.listMembers(dailyFile.getId());
|
||||
|
||||
+6
-1
@@ -1,8 +1,9 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -20,6 +21,10 @@ public class ShopManageCreateRequest {
|
||||
@NotBlank(message = "商城名称不能为空")
|
||||
private String mallName;
|
||||
|
||||
@Schema(description = "自动化账号")
|
||||
@Size(max = 128, message = "自动化账号长度不能超过128个字符")
|
||||
private String znUsername;
|
||||
|
||||
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String account;
|
||||
|
||||
+6
-1
@@ -1,8 +1,9 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -20,6 +21,10 @@ public class ShopManageUpdateRequest {
|
||||
@NotBlank(message = "商城名称不能为空")
|
||||
private String mallName;
|
||||
|
||||
@Schema(description = "自动化账号")
|
||||
@Size(max = 128, message = "自动化账号长度不能超过128个字符")
|
||||
private String znUsername;
|
||||
|
||||
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String account;
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ public class ShopKeyEntity {
|
||||
private String remarkName;
|
||||
private String ziniaoAccountName;
|
||||
private String ziniaoToken;
|
||||
private String ziniaoTokenHash;
|
||||
private String ipWhitelistStatus;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime ipWhitelistCheckedAt;
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ public class ShopManageEntity {
|
||||
private Long createdById;
|
||||
@TableField("mall_name")
|
||||
private String mallName;
|
||||
private String znUsername;
|
||||
private String account;
|
||||
private String password;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ public class ShopManageCredentialVo {
|
||||
private String groupName;
|
||||
private String shopName;
|
||||
private String mallName;
|
||||
private String znUsername;
|
||||
private String account;
|
||||
private String password;
|
||||
}
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ public class ShopManageItemVo {
|
||||
private String groupName;
|
||||
private String shopName;
|
||||
private String mallName;
|
||||
private String znUsername;
|
||||
private String account;
|
||||
private String password;
|
||||
private String passwordMasked;
|
||||
|
||||
+65
-5
@@ -11,9 +11,12 @@ import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@@ -47,13 +50,16 @@ public class ShopKeyService {
|
||||
@Transactional
|
||||
public ShopKeyItemVo create(ShopKeyCreateRequest request) {
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
String ziniaoToken = normalizeToken(request.getZiniaoToken());
|
||||
String ziniaoTokenHash = hashToken(ziniaoToken);
|
||||
ensureTokenAvailable(ziniaoTokenHash, null);
|
||||
ShopKeyEntity entity = new ShopKeyEntity();
|
||||
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
entity.setZiniaoTokenHash(ziniaoTokenHash);
|
||||
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
|
||||
shopKeyMapper.insert(entity);
|
||||
insertOrThrowDuplicateToken(entity);
|
||||
triggerShopIndexRefresh();
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
@@ -62,17 +68,20 @@ public class ShopKeyService {
|
||||
public ShopKeyItemVo update(Long id, ShopKeyUpdateRequest request) {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
boolean tokenChanged = !ziniaoToken.equals(entity.getZiniaoToken());
|
||||
String ziniaoToken = normalizeToken(request.getZiniaoToken());
|
||||
String ziniaoTokenHash = hashToken(ziniaoToken);
|
||||
ensureTokenAvailable(ziniaoTokenHash, id);
|
||||
boolean tokenChanged = !ziniaoTokenHash.equals(entity.getZiniaoTokenHash());
|
||||
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
entity.setZiniaoTokenHash(ziniaoTokenHash);
|
||||
if (tokenChanged) {
|
||||
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
|
||||
entity.setIpWhitelistCheckedAt(null);
|
||||
entity.setIpWhitelistMessage(null);
|
||||
}
|
||||
shopKeyMapper.updateById(entity);
|
||||
updateOrThrowDuplicateToken(entity);
|
||||
triggerShopIndexRefresh();
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
@@ -125,4 +134,55 @@ public class ShopKeyService {
|
||||
log.warn("[ziniao-index] refresh trigger failed after shop key change: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureTokenAvailable(String tokenHash, Long currentId) {
|
||||
ShopKeyEntity existing = shopKeyMapper.selectOne(new LambdaQueryWrapper<ShopKeyEntity>()
|
||||
.eq(ShopKeyEntity::getZiniaoTokenHash, tokenHash)
|
||||
.ne(currentId != null, ShopKeyEntity::getId, currentId)
|
||||
.last("LIMIT 1"));
|
||||
if (existing != null) {
|
||||
throw new BusinessException("紫鸟令牌已存在,不能重复维护");
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOrThrowDuplicateToken(ShopKeyEntity entity) {
|
||||
try {
|
||||
shopKeyMapper.insert(entity);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
throw new BusinessException("紫鸟令牌已存在,不能重复维护", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateOrThrowDuplicateToken(ShopKeyEntity entity) {
|
||||
try {
|
||||
shopKeyMapper.updateById(entity);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
throw new BusinessException("紫鸟令牌已存在,不能重复维护", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeToken(String value) {
|
||||
String normalized = normalizeRequired(value, "紫鸟令牌不能为空");
|
||||
if (normalized.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
normalized = normalized.substring(7).trim();
|
||||
}
|
||||
if (normalized.isEmpty()) {
|
||||
throw new BusinessException("紫鸟令牌不能为空");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(token.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||
for (byte value : digest) {
|
||||
result.append(String.format("%02x", value));
|
||||
}
|
||||
return result.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("紫鸟令牌指纹生成失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -94,6 +94,7 @@ public class ShopManageService {
|
||||
entity.setShopName(shopName);
|
||||
entity.setCreatedById(createdById);
|
||||
entity.setMallName(mallName);
|
||||
entity.setZnUsername(normalizeOptional(request.getZnUsername()));
|
||||
entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空"));
|
||||
entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空")));
|
||||
shopManageMapper.insert(entity);
|
||||
@@ -114,6 +115,9 @@ public class ShopManageService {
|
||||
entity.setGroupName(group.getGroupName());
|
||||
entity.setShopName(shopName);
|
||||
entity.setMallName(mallName);
|
||||
if (request.getZnUsername() != null) {
|
||||
entity.setZnUsername(normalizeOptional(request.getZnUsername()));
|
||||
}
|
||||
entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空"));
|
||||
entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空")));
|
||||
shopManageMapper.updateById(entity);
|
||||
@@ -141,6 +145,7 @@ public class ShopManageService {
|
||||
vo.setGroupId(entity.getGroupId());
|
||||
vo.setShopName(entity.getShopName());
|
||||
vo.setMallName(entity.getMallName());
|
||||
vo.setZnUsername(entity.getZnUsername());
|
||||
vo.setAccount(entity.getAccount());
|
||||
vo.setPassword(shopCredentialCryptoService.decrypt(entity.getPassword()));
|
||||
try {
|
||||
@@ -195,6 +200,10 @@ public class ShopManageService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeOptional(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private Long normalizePositiveId(Long value, String message) {
|
||||
if (value == null || value <= 0) {
|
||||
throw new BusinessException(message);
|
||||
@@ -221,6 +230,7 @@ public class ShopManageService {
|
||||
vo.setGroupName(groupName == null ? "" : groupName);
|
||||
vo.setShopName(entity.getShopName());
|
||||
vo.setMallName(entity.getMallName());
|
||||
vo.setZnUsername(entity.getZnUsername());
|
||||
vo.setAccount(entity.getAccount());
|
||||
String masked = entity.getPassword() == null || entity.getPassword().isBlank() ? "" : "******";
|
||||
vo.setPassword(masked);
|
||||
|
||||
+16
-1
@@ -221,9 +221,24 @@ public class SkipPriceAsinService {
|
||||
entity.setGroupId(group.getId());
|
||||
entity.setShopName(shopName);
|
||||
|
||||
SkipPriceAsinEntity firstExisting = null;
|
||||
boolean acceptedAny = false;
|
||||
for (Map.Entry<String, String> entry : countryAsinMap.entrySet()) {
|
||||
String country = entry.getKey();
|
||||
setCountryData(entity, country, entry.getValue(), countryMinimumPriceMap.get(country));
|
||||
String asin = entry.getValue();
|
||||
SkipPriceAsinEntity existing = findCountryAsin(group.getId(), shopName, country, asin);
|
||||
if (existing != null) {
|
||||
if (firstExisting == null) {
|
||||
firstExisting = existing;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
setCountryData(entity, country, asin, countryMinimumPriceMap.get(country));
|
||||
acceptedAny = true;
|
||||
}
|
||||
|
||||
if (!acceptedAny) {
|
||||
return toItemVo(firstExisting, group.getGroupName());
|
||||
}
|
||||
|
||||
skipPriceAsinMapper.insert(entity);
|
||||
|
||||
+25
@@ -214,6 +214,14 @@ public class ShopMatchTaskCacheService {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
continue;
|
||||
}
|
||||
if (!isFileCacheFresh(file, now)) {
|
||||
// 文件超过 TTL,视为过期并删除,避免被 poll 线程无限信任导致前端轮询不收敛
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
FileTaskEntity task = objectMapper.readValue(Files.readString(file), FileTaskEntity.class);
|
||||
result.put(taskId, task);
|
||||
@@ -245,5 +253,22 @@ public class ShopMatchTaskCacheService {
|
||||
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件缓存新鲜度判断:超过 {@code localTaskEntityFileCacheMillis} 视为过期。
|
||||
* 通过文件 mtime 判断,避免在 finalize 与 poll 线程的竞态下把陈旧的 RUNNING 写回后被永久信任。
|
||||
*/
|
||||
private boolean isFileCacheFresh(Path file, long now) {
|
||||
long ttl = Math.max(0L, taskPressureProperties.getLocalTaskEntityFileCacheMillis());
|
||||
if (ttl <= 0L) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
long modified = Files.getLastModifiedTime(file).toMillis();
|
||||
return now - modified <= ttl;
|
||||
} catch (IOException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||
}
|
||||
|
||||
+20
-3
@@ -123,14 +123,31 @@ public class ShopMatchTaskService {
|
||||
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
|
||||
continue;
|
||||
}
|
||||
result.put(dbTask.getId(), dbTask);
|
||||
if ("RUNNING".equals(dbTask.getStatus()) || "SCHEDULED".equals(dbTask.getStatus())) {
|
||||
shopMatchTaskCacheService.saveTaskCache(dbTask);
|
||||
// 复活防护:避免陈旧 RUNNING 把 finalize 后的 SUCCESS 覆盖掉
|
||||
if (!isCacheNewerThanDb(result.get(dbTask.getId()), dbTask)) {
|
||||
result.put(dbTask.getId(), dbTask);
|
||||
if ("RUNNING".equals(dbTask.getStatus()) || "SCHEDULED".equals(dbTask.getStatus())) {
|
||||
shopMatchTaskCacheService.saveTaskCache(dbTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当缓存中已存在的实体比本次 DB 读取的结果"更新"(updatedAt 不晚于 DB)时,
|
||||
* 认为缓存为权威值,避免陈旧的 DB 读把缓存中的终态覆盖回 RUNNING。
|
||||
*/
|
||||
private boolean isCacheNewerThanDb(FileTaskEntity cached, FileTaskEntity dbTask) {
|
||||
if (cached == null || dbTask == null) {
|
||||
return false;
|
||||
}
|
||||
if (cached.getUpdatedAt() == null || dbTask.getUpdatedAt() == null) {
|
||||
return false;
|
||||
}
|
||||
return !cached.getUpdatedAt().isBefore(dbTask.getUpdatedAt());
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
|
||||
+7
@@ -181,6 +181,13 @@ public class ZiniaoMemoryStoreService {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteAllByType(String cacheType) {
|
||||
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
|
||||
return ziniaoMemoryStoreMapper.delete(new LambdaQueryWrapper<ZiniaoMemoryStoreEntity>()
|
||||
.eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteExpired(int limit) {
|
||||
int safeLimit = Math.max(limit, 1);
|
||||
|
||||
+13
@@ -105,6 +105,19 @@ public class ZiniaoTransientCacheService {
|
||||
log.trace("[ziniao-transient] delete type={}", normalizedType);
|
||||
}
|
||||
|
||||
public int deleteByType(String cacheType) {
|
||||
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
|
||||
String prefix = normalizedType + SEP;
|
||||
int deleted = 0;
|
||||
for (String key : map.keySet()) {
|
||||
if (key.startsWith(prefix) && map.remove(key) != null) {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
log.trace("[ziniao-transient] delete by type={} count={}", normalizedType, deleted);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.ziniao.transient-cache-cleanup-delay-ms:60000}")
|
||||
void cleanupExpiredEntriesScheduled() {
|
||||
cleanupExpiredEntries(LocalDateTime.now());
|
||||
|
||||
+17
-2
@@ -44,6 +44,11 @@ public class ZiniaoShopIndexService {
|
||||
|
||||
private static final String CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT = "SHOP_INDEX_SCOPE_SNAPSHOT";
|
||||
private static final String CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR = "SHOP_INDEX_REFRESH_CURSOR";
|
||||
private static final String CACHE_TYPE_COMPANY_ID = "COMPANY_ID";
|
||||
private static final String CACHE_TYPE_STAFF_LIST = "STAFF_LIST";
|
||||
private static final String CACHE_TYPE_USER_STORES = "USER_STORES";
|
||||
private static final String CACHE_TYPE_INVALID_USER_STORES = "INVALID_USER_STORES";
|
||||
private static final String CACHE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
||||
/** 有 shopId 时唯一键,避免同一店铺因不同员工/哈希产生多行。 */
|
||||
private static final String SHOP_ENTRY_KEY_SHOP_PREFIX = "s:";
|
||||
/** 无 shopId(冲突占位等)时仍按规范化店名存一行。 */
|
||||
@@ -414,8 +419,18 @@ public class ZiniaoShopIndexService {
|
||||
}
|
||||
|
||||
public void invalidateIndex() {
|
||||
ziniaoTransientCacheService.delete(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global");
|
||||
log.info("[ziniao-index] cursor invalidated (transient only; shop rows unchanged)");
|
||||
int persistentDeleted = ziniaoMemoryStoreService.deleteAllByType(
|
||||
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY);
|
||||
int transientDeleted = 0;
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_COMPANY_ID);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_STAFF_LIST);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_USER_STORES);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_INVALID_USER_STORES);
|
||||
transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_MATCH);
|
||||
log.info("[ziniao-index] invalidated persistentRows={} transientEntries={} next refresh starts from offset 0",
|
||||
persistentDeleted, transientDeleted);
|
||||
}
|
||||
|
||||
private void markIpWhitelistAllowedSafely(ZiniaoApiKeyProvider.ApiKeyAccount account) {
|
||||
|
||||
@@ -8,6 +8,9 @@ spring:
|
||||
multipart:
|
||||
max-file-size: 2GB
|
||||
max-request-size: 2GB
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: ${AIIMAGE_MVC_ASYNC_REQUEST_TIMEOUT:30m}
|
||||
jackson:
|
||||
time-zone: Asia/Shanghai
|
||||
datasource:
|
||||
@@ -24,6 +27,13 @@ spring:
|
||||
max-lifetime: ${AIIMAGE_DB_POOL_MAX_LIFETIME_MS:1500000}
|
||||
keepalive-time: ${AIIMAGE_DB_POOL_KEEPALIVE_TIME_MS:120000}
|
||||
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:15000}
|
||||
flyway:
|
||||
enabled: ${AIIMAGE_FLYWAY_ENABLED:true}
|
||||
locations: classpath:db
|
||||
baseline-on-migrate: ${AIIMAGE_FLYWAY_BASELINE_ON_MIGRATE:true}
|
||||
baseline-version: ${AIIMAGE_FLYWAY_BASELINE_VERSION:88}
|
||||
baseline-description: ${AIIMAGE_FLYWAY_BASELINE_DESCRIPTION:existing-schema}
|
||||
validate-on-migrate: true
|
||||
data:
|
||||
redis:
|
||||
username: ${AIIMAGE_REDIS_USERNAME:}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE biz_shop_manage
|
||||
ADD COLUMN zn_username VARCHAR(128) NOT NULL DEFAULT '' COMMENT '自动化账号' AFTER mall_name;
|
||||
|
||||
ALTER TABLE biz_shop_key
|
||||
ADD COLUMN ziniao_token_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '规范化紫鸟令牌 SHA-256' AFTER ziniao_token;
|
||||
|
||||
UPDATE biz_shop_key
|
||||
SET ziniao_token = TRIM(
|
||||
CASE
|
||||
WHEN LOWER(LEFT(TRIM(ziniao_token), 7)) = 'bearer '
|
||||
THEN SUBSTRING(TRIM(ziniao_token), 8)
|
||||
ELSE TRIM(ziniao_token)
|
||||
END
|
||||
);
|
||||
|
||||
UPDATE biz_shop_key
|
||||
SET ziniao_token_hash = SHA2(ziniao_token, 256);
|
||||
|
||||
ALTER TABLE biz_shop_key
|
||||
ADD UNIQUE KEY uk_ziniao_token_hash (ziniao_token_hash);
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.nanri.aiimage.modules.dedupe.controller;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.dedupe.service.DedupeTotalDataService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DedupeTotalDataControllerTest {
|
||||
|
||||
@Test
|
||||
void exportReturnsStreamingBodyAndDelegatesToService() throws Exception {
|
||||
DedupeTotalDataService service = mock(DedupeTotalDataService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
DedupeTotalDataController controller = new DedupeTotalDataController(
|
||||
service, authSupport, permissionMenuService);
|
||||
AdminUserEntity operator = new AdminUserEntity();
|
||||
operator.setId(8L);
|
||||
operator.setRole("super_admin");
|
||||
when(authSupport.requireUser(request)).thenReturn(operator);
|
||||
when(authSupport.currentRole(operator)).thenReturn("super_admin");
|
||||
doAnswer(invocation -> {
|
||||
OutputStream outputStream = invocation.getArgument(0);
|
||||
outputStream.write(new byte[]{1, 2, 3});
|
||||
return null;
|
||||
}).when(service).writeExport(
|
||||
any(OutputStream.class),
|
||||
eq("member"),
|
||||
eq(LocalDate.of(2026, 7, 1)),
|
||||
eq(LocalDate.of(2026, 7, 31)),
|
||||
eq(3L),
|
||||
eq(8L));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response = controller.export(
|
||||
"member",
|
||||
LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2026, 7, 31),
|
||||
3L,
|
||||
request);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody() instanceof StreamingResponseBody);
|
||||
assertEquals(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
response.getHeaders().getContentType());
|
||||
assertNotNull(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
|
||||
assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)
|
||||
.contains("dedupe-total-data-"));
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
response.getBody().writeTo(outputStream);
|
||||
|
||||
assertArrayEquals(new byte[]{1, 2, 3}, outputStream.toByteArray());
|
||||
verify(service).writeExport(
|
||||
any(OutputStream.class),
|
||||
eq("member"),
|
||||
eq(LocalDate.of(2026, 7, 1)),
|
||||
eq(LocalDate.of(2026, 7, 31)),
|
||||
eq(3L),
|
||||
eq(8L));
|
||||
verify(service, never()).writeMonthlyZipExport(
|
||||
any(OutputStream.class), any(), any(), any(), any(), any());
|
||||
verifyNoInteractions(permissionMenuService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportWithIncompleteDatesStaysSingleXlsx() throws Exception {
|
||||
DedupeTotalDataService service = mock(DedupeTotalDataService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
DedupeTotalDataController controller = new DedupeTotalDataController(
|
||||
service, authSupport, permissionMenuService);
|
||||
AdminUserEntity operator = new AdminUserEntity();
|
||||
operator.setId(8L);
|
||||
operator.setRole("super_admin");
|
||||
when(authSupport.requireUser(request)).thenReturn(operator);
|
||||
when(authSupport.currentRole(operator)).thenReturn("super_admin");
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response = controller.export(
|
||||
"member",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 2),
|
||||
3L,
|
||||
request);
|
||||
|
||||
assertEquals(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
response.getHeaders().getContentType());
|
||||
assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)
|
||||
.contains(".xlsx"));
|
||||
assertNotNull(response.getBody());
|
||||
response.getBody().writeTo(new ByteArrayOutputStream());
|
||||
|
||||
verify(service).writeExport(
|
||||
any(OutputStream.class),
|
||||
eq("member"),
|
||||
isNull(),
|
||||
eq(LocalDate.of(2026, 8, 2)),
|
||||
eq(3L),
|
||||
eq(8L));
|
||||
verify(service, never()).writeMonthlyZipExport(
|
||||
any(OutputStream.class), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossMonthExportReturnsZipAndDelegatesMonthlyExport() throws Exception {
|
||||
DedupeTotalDataService service = mock(DedupeTotalDataService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
DedupeTotalDataController controller = new DedupeTotalDataController(
|
||||
service, authSupport, permissionMenuService);
|
||||
AdminUserEntity operator = new AdminUserEntity();
|
||||
operator.setId(8L);
|
||||
operator.setRole("super_admin");
|
||||
when(authSupport.requireUser(request)).thenReturn(operator);
|
||||
when(authSupport.currentRole(operator)).thenReturn("super_admin");
|
||||
doAnswer(invocation -> {
|
||||
OutputStream outputStream = invocation.getArgument(0);
|
||||
outputStream.write(new byte[]{4, 5});
|
||||
return null;
|
||||
}).when(service).writeMonthlyZipExport(
|
||||
any(OutputStream.class),
|
||||
eq("member"),
|
||||
eq(LocalDate.of(2026, 7, 15)),
|
||||
eq(LocalDate.of(2026, 8, 2)),
|
||||
eq(3L),
|
||||
eq(8L));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response = controller.export(
|
||||
"member",
|
||||
LocalDate.of(2026, 7, 15),
|
||||
LocalDate.of(2026, 8, 2),
|
||||
3L,
|
||||
request);
|
||||
|
||||
assertEquals(MediaType.parseMediaType("application/zip"), response.getHeaders().getContentType());
|
||||
assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)
|
||||
.contains(".zip"));
|
||||
assertNotNull(response.getBody());
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
response.getBody().writeTo(outputStream);
|
||||
|
||||
assertArrayEquals(new byte[]{4, 5}, outputStream.toByteArray());
|
||||
verify(service).writeMonthlyZipExport(
|
||||
any(OutputStream.class),
|
||||
eq("member"),
|
||||
eq(LocalDate.of(2026, 7, 15)),
|
||||
eq(LocalDate.of(2026, 8, 2)),
|
||||
eq(3L),
|
||||
eq(8L));
|
||||
verify(service, never()).writeExport(any(OutputStream.class), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reversedDateRangeIsRejectedBeforeStreamingBodyIsReturned() {
|
||||
DedupeTotalDataService service = mock(DedupeTotalDataService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionMenuService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
DedupeTotalDataController controller = new DedupeTotalDataController(
|
||||
service, authSupport, permissionMenuService);
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class, () -> controller.export(
|
||||
"member",
|
||||
LocalDate.of(2026, 8, 2),
|
||||
LocalDate.of(2026, 7, 15),
|
||||
3L,
|
||||
request));
|
||||
|
||||
assertEquals(400, exception.getCode());
|
||||
verifyNoInteractions(service, authSupport, permissionMenuService);
|
||||
}
|
||||
}
|
||||
+115
@@ -33,9 +33,13 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -312,6 +316,75 @@ class DedupeTotalDataServiceTest {
|
||||
verify(dedupeTotalDataMapper).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeExportPagesThroughLargeResultSet() throws Exception {
|
||||
when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root"));
|
||||
when(dedupeTotalDataMapper.selectList(any()))
|
||||
.thenReturn(exportBatch(5000L, 2000), List.of(data(3000L, 23L)));
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
service.writeExport(output, "", null, null, null, 1L);
|
||||
|
||||
verify(dedupeTotalDataMapper, times(2)).selectList(any());
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(output.toByteArray()))) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
assertEquals(2001, sheet.getLastRowNum());
|
||||
assertEquals("5000", sheet.getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("3000", sheet.getRow(2001).getCell(0).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void writeMonthlyZipExportSplitsByMonthAndClipsDateRanges() throws Exception {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DedupeTotalDataEntity.class);
|
||||
when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root"));
|
||||
when(shopManageGroupMapper.selectById(3L)).thenReturn(group(3L));
|
||||
DedupeTotalDataEntity july = data(500L, 23L);
|
||||
july.setDataValue("JULY");
|
||||
july.setCreatedAt(LocalDateTime.of(2026, 7, 31, 10, 0));
|
||||
DedupeTotalDataEntity august = data(400L, 23L);
|
||||
august.setDataValue("AUGUST");
|
||||
august.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
when(dedupeTotalDataMapper.selectList(any()))
|
||||
.thenReturn(List.of(july), List.of(august));
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
service.writeMonthlyZipExport(
|
||||
output,
|
||||
" member ",
|
||||
LocalDate.of(2026, 7, 15),
|
||||
LocalDate.of(2026, 8, 2),
|
||||
3L,
|
||||
1L);
|
||||
|
||||
Map<String, byte[]> entries = unzip(output.toByteArray());
|
||||
assertEquals(List.of(
|
||||
"dedupe-total-data-2026-07.xlsx",
|
||||
"dedupe-total-data-2026-08.xlsx"), new ArrayList<>(entries.keySet()));
|
||||
assertWorkbookDataValue(entries.get("dedupe-total-data-2026-07.xlsx"), "JULY");
|
||||
assertWorkbookDataValue(entries.get("dedupe-total-data-2026-08.xlsx"), "AUGUST");
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<DedupeTotalDataEntity>> queryCaptor =
|
||||
ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
|
||||
verify(dedupeTotalDataMapper, times(2)).selectList(queryCaptor.capture());
|
||||
List<LambdaQueryWrapper<DedupeTotalDataEntity>> queries = queryCaptor.getAllValues();
|
||||
assertQueryContainsText(queries.get(0), "member");
|
||||
assertQueryContains(queries.get(0),
|
||||
LocalDate.of(2026, 7, 15).atStartOfDay(),
|
||||
LocalDate.of(2026, 8, 1).atStartOfDay(),
|
||||
3L);
|
||||
assertQueryContainsText(queries.get(1), "member");
|
||||
assertQueryContains(queries.get(1),
|
||||
LocalDate.of(2026, 8, 1).atStartOfDay(),
|
||||
LocalDate.of(2026, 8, 3).atStartOfDay(),
|
||||
3L);
|
||||
verify(adminUserMapper).selectById(1L);
|
||||
verify(shopManageGroupMapper).selectById(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportRejectsReversedDateRange() {
|
||||
assertThrows(BusinessException.class, () -> service.export(
|
||||
@@ -404,6 +477,48 @@ class DedupeTotalDataServiceTest {
|
||||
return entity;
|
||||
}
|
||||
|
||||
private List<DedupeTotalDataEntity> exportBatch(long startId, int count) {
|
||||
List<DedupeTotalDataEntity> rows = new ArrayList<>(count);
|
||||
for (long id = startId; id > startId - count; id--) {
|
||||
rows.add(data(id, 23L));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<String, byte[]> unzip(byte[] bytes) throws Exception {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(bytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipInputStream.getNextEntry()) != null) {
|
||||
ByteArrayOutputStream entryOutput = new ByteArrayOutputStream();
|
||||
zipInputStream.transferTo(entryOutput);
|
||||
entries.put(entry.getName(), entryOutput.toByteArray());
|
||||
zipInputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private void assertWorkbookDataValue(byte[] bytes, String expectedValue) throws Exception {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
|
||||
assertEquals(expectedValue, workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void assertQueryContains(LambdaQueryWrapper<DedupeTotalDataEntity> query, Object... values) {
|
||||
query.getSqlSegment();
|
||||
for (Object value : values) {
|
||||
assertTrue(query.getParamNameValuePairs().containsValue(value));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertQueryContainsText(LambdaQueryWrapper<DedupeTotalDataEntity> query, String expectedText) {
|
||||
query.getSqlSegment();
|
||||
assertTrue(query.getParamNameValuePairs().values().stream()
|
||||
.map(String::valueOf)
|
||||
.anyMatch(value -> value.contains(expectedText)));
|
||||
}
|
||||
|
||||
private void stubWritableGroup(Long operatorId, Long groupId) {
|
||||
when(shopManageGroupMapper.selectAccessibleGroupIds(operatorId)).thenReturn(List.of(groupId));
|
||||
when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(groupId))).thenReturn(List.of(operatorId));
|
||||
|
||||
+155
@@ -16,10 +16,12 @@ import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -29,8 +31,10 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -146,4 +150,155 @@ class PriceTrackTaskServiceTest {
|
||||
verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId);
|
||||
verify(lock).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitResultComputesModifyCountFromOriginalRowsBeforeAssembly() {
|
||||
long taskId = 22165L;
|
||||
String shopName = "shop-a";
|
||||
|
||||
FileTaskEntity task = runningTask(taskId);
|
||||
FileResultEntity result = pendingResult(taskId, shopName);
|
||||
|
||||
PriceTrackSubmitResultRequest.AsinResult changed = asinRow("B001", "\u6539\u4ef7\u6210\u529f", null);
|
||||
PriceTrackSubmitResultRequest.AsinResult skipped = asinRow("B002", "\u8df3\u8fc7\uff0c\u65e0\u9700\u6539\u4ef7", null);
|
||||
PriceTrackSubmitResultRequest.AsinResult invalidBaseline = asinRow("B003", "UPDATED", null);
|
||||
PriceTrackSubmitResultRequest.AsinResult missingBaseline = asinRow("B004", "UPDATED", null);
|
||||
PriceTrackSubmitResultRequest.AsinResult statusOnly = asinRow("B005", null, "UPDATED");
|
||||
PriceTrackSubmitResultRequest.AsinResult blankBaseline = asinRow("B006", "UPDATED", null);
|
||||
changed.setModifyCount("1");
|
||||
skipped.setModifyCount("1");
|
||||
invalidBaseline.setModifyCount("1");
|
||||
missingBaseline.setModifyCount("1");
|
||||
statusOnly.setModifyCount("1");
|
||||
blankBaseline.setModifyCount("1");
|
||||
|
||||
PriceTrackSubmitResultRequest.ShopResult shopResult = shopResult(
|
||||
shopName,
|
||||
Map.of("DE", List.of(changed, skipped, invalidBaseline, missingBaseline, statusOnly, blankBaseline)));
|
||||
PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest();
|
||||
request.setShops(List.of(shopResult));
|
||||
|
||||
Map<String, List<Map<String, String>>> originalRows = Map.of("de", List.of(
|
||||
originalRow("b001", "3.0"),
|
||||
originalRow("b002", "5"),
|
||||
originalRow("b003", "abc"),
|
||||
originalRow("b005", ""),
|
||||
originalRow("b006", "")
|
||||
));
|
||||
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock);
|
||||
when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName);
|
||||
when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(taskResultPayloadService.getLatest(eq(taskId), eq("PRICE_TRACK"), eq("price-track-asin-rows"), eq(Map.class)))
|
||||
.thenReturn(originalRows);
|
||||
when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any())).thenReturn(Map.of());
|
||||
|
||||
service.submitResult(taskId, request);
|
||||
|
||||
ArgumentCaptor<PriceTrackSubmitResultRequest.ShopResult> payloadCaptor =
|
||||
ArgumentCaptor.forClass(PriceTrackSubmitResultRequest.ShopResult.class);
|
||||
verify(taskResultPayloadService).saveLatest(eq(taskId), eq("PRICE_TRACK"), eq(shopName), payloadCaptor.capture());
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> savedRows = payloadCaptor.getValue().getCountries().get("DE");
|
||||
assertEquals("4", savedRows.get(0).getModifyCount());
|
||||
assertEquals("5", savedRows.get(1).getModifyCount());
|
||||
assertEquals("1", savedRows.get(2).getModifyCount());
|
||||
assertEquals("1", savedRows.get(3).getModifyCount());
|
||||
assertEquals("0", savedRows.get(4).getModifyCount());
|
||||
assertEquals("1", savedRows.get(5).getModifyCount());
|
||||
verify(taskFileJobService).enqueueAssembleResult(taskId, "PRICE_TRACK", result.getId(), shopName);
|
||||
verify(lock).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitResultUsesZeroBaselineWhenOriginalRowsPayloadIsMissing() {
|
||||
long taskId = 22166L;
|
||||
String shopName = "shop-b";
|
||||
|
||||
FileTaskEntity task = runningTask(taskId);
|
||||
FileResultEntity result = pendingResult(taskId, shopName);
|
||||
|
||||
PriceTrackSubmitResultRequest.AsinResult changed = asinRow("B010", "UPDATED", null);
|
||||
PriceTrackSubmitResultRequest.AsinResult skipped = asinRow("B011", "\u8df3\u8fc7\uff0c\u65e0\u9700\u6539\u4ef7", null);
|
||||
changed.setModifyCount("99");
|
||||
skipped.setModifyCount("99");
|
||||
|
||||
PriceTrackSubmitResultRequest.ShopResult shopResult = shopResult(
|
||||
shopName,
|
||||
Map.of("UK", List.of(changed, skipped)));
|
||||
PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest();
|
||||
request.setShops(List.of(shopResult));
|
||||
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock);
|
||||
when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName);
|
||||
when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(taskResultPayloadService.getLatest(eq(taskId), eq("PRICE_TRACK"), eq("price-track-asin-rows"), eq(Map.class)))
|
||||
.thenReturn(null);
|
||||
when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any())).thenReturn(Map.of());
|
||||
|
||||
service.submitResult(taskId, request);
|
||||
service.submitResult(taskId, request);
|
||||
|
||||
ArgumentCaptor<PriceTrackSubmitResultRequest.ShopResult> payloadCaptor =
|
||||
ArgumentCaptor.forClass(PriceTrackSubmitResultRequest.ShopResult.class);
|
||||
verify(taskResultPayloadService, times(2))
|
||||
.saveLatest(eq(taskId), eq("PRICE_TRACK"), eq(shopName), payloadCaptor.capture());
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> savedRows = payloadCaptor.getAllValues().get(1).getCountries().get("UK");
|
||||
assertEquals("1", savedRows.get(0).getModifyCount());
|
||||
assertEquals("0", savedRows.get(1).getModifyCount());
|
||||
verify(taskFileJobService, times(2)).enqueueAssembleResult(taskId, "PRICE_TRACK", result.getId(), shopName);
|
||||
verify(lock, times(2)).close();
|
||||
}
|
||||
|
||||
private FileTaskEntity runningTask(long taskId) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setUserId(672L);
|
||||
task.setModuleType("PRICE_TRACK");
|
||||
task.setStatus("RUNNING");
|
||||
task.setRequestJson("{}");
|
||||
return task;
|
||||
}
|
||||
|
||||
private FileResultEntity pendingResult(long taskId, String shopName) {
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(taskId + 1000L);
|
||||
result.setTaskId(taskId);
|
||||
result.setModuleType("PRICE_TRACK");
|
||||
result.setSourceFilename(shopName);
|
||||
result.setSuccess(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
private PriceTrackSubmitResultRequest.ShopResult shopResult(
|
||||
String shopName,
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
|
||||
PriceTrackSubmitResultRequest.ShopResult shopResult = new PriceTrackSubmitResultRequest.ShopResult();
|
||||
shopResult.setShopName(shopName);
|
||||
shopResult.setCountries(countries);
|
||||
shopResult.setSuccess(true);
|
||||
shopResult.setError("");
|
||||
return shopResult;
|
||||
}
|
||||
|
||||
private PriceTrackSubmitResultRequest.AsinResult asinRow(String asin, String priceChangeStatus, String status) {
|
||||
PriceTrackSubmitResultRequest.AsinResult row = new PriceTrackSubmitResultRequest.AsinResult();
|
||||
row.setShopMallName("mall");
|
||||
row.setAsin(asin);
|
||||
row.setPriceChangeStatus(priceChangeStatus);
|
||||
row.setStatus(status);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Map<String, String> originalRow(String asin, String modifyCount) {
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
row.put("asin", asin);
|
||||
row.put("modifyCount", modifyCount);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
+138
-20
@@ -5,23 +5,30 @@ import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -40,18 +47,40 @@ class SkipPriceAsinServiceTest {
|
||||
private SkipPriceAsinService service;
|
||||
|
||||
@Test
|
||||
void createInsertsNewRowWhenGroupAndShopAlreadyExist() {
|
||||
ShopManageGroupEntity group = new ShopManageGroupEntity();
|
||||
group.setId(10L);
|
||||
group.setGroupName("group-a");
|
||||
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group);
|
||||
void createSkipsDuplicateCountryAsinWithoutWriting() {
|
||||
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group());
|
||||
|
||||
SkipPriceAsinEntity existing = new SkipPriceAsinEntity();
|
||||
existing.setId(100L);
|
||||
existing.setGroupId(10L);
|
||||
existing.setShopName("shop-a");
|
||||
existing.setAsinDe("OLD-ASIN");
|
||||
lenient().when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing);
|
||||
existing.setAsinDe("DUP-ASIN");
|
||||
existing.setMinimumPriceDe(new BigDecimal("14.00"));
|
||||
when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
SkipPriceAsinItemVo result = service.create(request(List.of("DE"),
|
||||
Map.of("DE", "dup-asin"),
|
||||
Map.of("DE", new BigDecimal("99.99"))), 7L, true);
|
||||
|
||||
verify(skipPriceAsinMapper, never()).insert(any(SkipPriceAsinEntity.class));
|
||||
verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class));
|
||||
assertEquals(100L, result.getId());
|
||||
assertEquals("DUP-ASIN", result.getAsinDe());
|
||||
assertEquals(new BigDecimal("14.00"), result.getMinimumPriceDe());
|
||||
assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceDe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInsertsOnlyNonDuplicateCountriesWithoutUpdatingExisting() {
|
||||
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group());
|
||||
|
||||
SkipPriceAsinEntity existing = new SkipPriceAsinEntity();
|
||||
existing.setId(100L);
|
||||
existing.setGroupId(10L);
|
||||
existing.setShopName("shop-a");
|
||||
existing.setAsinDe("DUP-ASIN");
|
||||
existing.setMinimumPriceDe(new BigDecimal("14.00"));
|
||||
when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing).thenReturn(null);
|
||||
|
||||
AtomicReference<SkipPriceAsinEntity> inserted = new AtomicReference<>();
|
||||
when(skipPriceAsinMapper.insert(any(SkipPriceAsinEntity.class))).thenAnswer(invocation -> {
|
||||
@@ -62,23 +91,112 @@ class SkipPriceAsinServiceTest {
|
||||
});
|
||||
when(skipPriceAsinMapper.selectById(101L)).thenAnswer(invocation -> inserted.get());
|
||||
|
||||
SkipPriceAsinCreateRequest request = new SkipPriceAsinCreateRequest();
|
||||
request.setGroupId(10L);
|
||||
request.setShopName("shop-a");
|
||||
request.setCountries(List.of("DE"));
|
||||
request.setAsinMappings(Map.of("DE", "NEW-ASIN"));
|
||||
request.setMinimumPriceMappings(Map.of("DE", new BigDecimal("19.99")));
|
||||
|
||||
SkipPriceAsinItemVo result = service.create(request, 7L, true);
|
||||
SkipPriceAsinItemVo result = service.create(request(List.of("DE", "UK"),
|
||||
Map.of("DE", "dup-asin", "UK", "new-asin"),
|
||||
Map.of("DE", new BigDecimal("99.99"), "UK", new BigDecimal("19.99"))), 7L, true);
|
||||
|
||||
ArgumentCaptor<SkipPriceAsinEntity> captor = ArgumentCaptor.forClass(SkipPriceAsinEntity.class);
|
||||
verify(skipPriceAsinMapper).insert(captor.capture());
|
||||
verify(skipPriceAsinMapper, never()).selectOne(any());
|
||||
verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class));
|
||||
assertNotSame(existing, captor.getValue());
|
||||
assertEquals("OLD-ASIN", existing.getAsinDe());
|
||||
assertNull(captor.getValue().getAsinDe());
|
||||
assertNull(captor.getValue().getMinimumPriceDe());
|
||||
assertEquals("NEW-ASIN", captor.getValue().getAsinUk());
|
||||
assertEquals(new BigDecimal("19.99"), captor.getValue().getMinimumPriceUk());
|
||||
assertEquals("DUP-ASIN", existing.getAsinDe());
|
||||
assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceDe());
|
||||
assertEquals(101L, result.getId());
|
||||
assertEquals("NEW-ASIN", result.getAsinUk());
|
||||
assertEquals(new BigDecimal("19.99"), result.getMinimumPriceUk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInsertsDifferentAsinForSameShopAndCountry() {
|
||||
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group());
|
||||
when(skipPriceAsinMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
AtomicReference<SkipPriceAsinEntity> inserted = new AtomicReference<>();
|
||||
when(skipPriceAsinMapper.insert(any(SkipPriceAsinEntity.class))).thenAnswer(invocation -> {
|
||||
SkipPriceAsinEntity entity = invocation.getArgument(0);
|
||||
entity.setId(101L);
|
||||
inserted.set(entity);
|
||||
return 1;
|
||||
});
|
||||
when(skipPriceAsinMapper.selectById(101L)).thenAnswer(invocation -> inserted.get());
|
||||
|
||||
SkipPriceAsinItemVo result = service.create(request(List.of("DE"),
|
||||
Map.of("DE", "new-asin"),
|
||||
Map.of("DE", new BigDecimal("19.99"))), 7L, true);
|
||||
|
||||
ArgumentCaptor<SkipPriceAsinEntity> captor = ArgumentCaptor.forClass(SkipPriceAsinEntity.class);
|
||||
verify(skipPriceAsinMapper).insert(captor.capture());
|
||||
verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class));
|
||||
assertEquals("NEW-ASIN", captor.getValue().getAsinDe());
|
||||
assertEquals(new BigDecimal("19.99"), captor.getValue().getMinimumPriceDe());
|
||||
assertEquals(101L, result.getId());
|
||||
assertEquals("NEW-ASIN", result.getAsinDe());
|
||||
assertEquals(new BigDecimal("19.99"), result.getMinimumPriceDe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importSkipsDuplicateCountryAsinWithoutWriting() throws Exception {
|
||||
SkipPriceAsinEntity existing = new SkipPriceAsinEntity();
|
||||
existing.setId(100L);
|
||||
existing.setGroupId(10L);
|
||||
existing.setShopName("shop-a");
|
||||
existing.setAsinUk("DUP-ASIN");
|
||||
existing.setMinimumPriceUk(new BigDecimal("14.00"));
|
||||
when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
File workbookFile = importWorkbook("DUP-ASIN", "99.99");
|
||||
QueryAsinImportProgressVo progress = new QueryAsinImportProgressVo();
|
||||
try {
|
||||
ReflectionTestUtils.invokeMethod(service, "processImportFile",
|
||||
workbookFile, "shop-a.xlsx", 10L, "shop-a", false, progress);
|
||||
} finally {
|
||||
Files.deleteIfExists(workbookFile.toPath());
|
||||
}
|
||||
|
||||
verify(skipPriceAsinMapper, never()).insert(any(SkipPriceAsinEntity.class));
|
||||
verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class));
|
||||
assertEquals(1, progress.getTotalRows());
|
||||
assertEquals(1, progress.getProcessedRows());
|
||||
assertEquals(1, progress.getAsinCount());
|
||||
assertEquals(0, progress.getInsertedCount());
|
||||
assertEquals(1, progress.getSkippedCount());
|
||||
assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceUk());
|
||||
}
|
||||
|
||||
private ShopManageGroupEntity group() {
|
||||
ShopManageGroupEntity group = new ShopManageGroupEntity();
|
||||
group.setId(10L);
|
||||
group.setGroupName("group-a");
|
||||
return group;
|
||||
}
|
||||
|
||||
private SkipPriceAsinCreateRequest request(List<String> countries, Map<String, String> asinMappings,
|
||||
Map<String, BigDecimal> minimumPriceMappings) {
|
||||
SkipPriceAsinCreateRequest request = new SkipPriceAsinCreateRequest();
|
||||
request.setGroupId(10L);
|
||||
request.setShopName("shop-a");
|
||||
request.setCountries(countries);
|
||||
request.setAsinMappings(asinMappings);
|
||||
request.setMinimumPriceMappings(minimumPriceMappings);
|
||||
return request;
|
||||
}
|
||||
|
||||
private File importWorkbook(String asin, String minimumPrice) throws Exception {
|
||||
File file = File.createTempFile("skip-price-asin-test-", ".xlsx");
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
FileOutputStream outputStream = new FileOutputStream(file)) {
|
||||
Sheet sheet = workbook.createSheet("import");
|
||||
sheet.createRow(0).createCell(0).setCellValue("英国");
|
||||
sheet.getRow(0).createCell(1).setCellValue("英国");
|
||||
sheet.createRow(1).createCell(0).setCellValue("ASIN");
|
||||
sheet.getRow(1).createCell(1).setCellValue("最低价");
|
||||
sheet.createRow(2).createCell(0).setCellValue(asin);
|
||||
sheet.getRow(2).createCell(1).setCellValue(minimumPrice);
|
||||
workbook.write(outputStream);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,19 +1356,7 @@ _SHOP_DATA_CRAWL_ADMIN_COLUMNS = f"""
|
||||
t.task_no, t.status AS task_status, t.request_json, t.result_json,
|
||||
t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at,
|
||||
{_SHOP_DATA_CRAWL_LATEST_TIME_SQL} AS latest_file_updated_at,
|
||||
u.username,
|
||||
(SELECT j.id FROM biz_task_file_job j
|
||||
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
|
||||
AND j.job_type = 'ASSEMBLE_RESULT'
|
||||
ORDER BY j.id DESC LIMIT 1) AS file_job_id,
|
||||
(SELECT j.status FROM biz_task_file_job j
|
||||
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
|
||||
AND j.job_type = 'ASSEMBLE_RESULT'
|
||||
ORDER BY j.id DESC LIMIT 1) AS file_status,
|
||||
(SELECT j.error_message FROM biz_task_file_job j
|
||||
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
|
||||
AND j.job_type = 'ASSEMBLE_RESULT'
|
||||
ORDER BY j.id DESC LIMIT 1) AS file_error
|
||||
u.username
|
||||
"""
|
||||
|
||||
|
||||
@@ -1599,7 +1587,34 @@ def list_shop_data_crawl_tasks():
|
||||
'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC',
|
||||
tuple(params + selected_shop_names),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
all_result_rows = cur.fetchall()
|
||||
|
||||
result_ids = [int(row['result_id']) for row in all_result_rows if row.get('result_id')]
|
||||
file_job_map = {}
|
||||
if result_ids:
|
||||
fj_placeholders = ','.join(['%s'] * len(result_ids))
|
||||
cur.execute(
|
||||
'SELECT fj.result_id, fj.id AS file_job_id, fj.status AS file_status, '
|
||||
'fj.error_message AS file_error '
|
||||
'FROM biz_task_file_job fj '
|
||||
f'INNER JOIN (SELECT result_id, MAX(id) AS max_id '
|
||||
f'FROM biz_task_file_job '
|
||||
f"WHERE module_type = 'SHOP_DATA_CRAWL' AND job_type = 'ASSEMBLE_RESULT' "
|
||||
f'AND result_id IN (' + fj_placeholders + ') '
|
||||
f'GROUP BY result_id) latest '
|
||||
'ON fj.id = latest.max_id',
|
||||
tuple(result_ids),
|
||||
)
|
||||
for fj_row in cur.fetchall():
|
||||
file_job_map[int(fj_row['result_id'])] = fj_row
|
||||
|
||||
for row in all_result_rows:
|
||||
rid = int(row.get('result_id') or 0)
|
||||
fj = file_job_map.get(rid)
|
||||
if fj:
|
||||
row['file_job_id'] = fj.get('file_job_id')
|
||||
row['file_status'] = fj.get('file_status')
|
||||
row['file_error'] = fj.get('file_error')
|
||||
shop_key = _shop_data_crawl_shop_key(row.get('shop_name'))
|
||||
result_rows_by_shop.setdefault(shop_key, []).append(row)
|
||||
finally:
|
||||
@@ -2997,7 +3012,7 @@ def export_dedupe_total_data():
|
||||
params=params,
|
||||
headers={'X-Internal-Token': _resolve_internal_token()},
|
||||
stream=True,
|
||||
timeout=60,
|
||||
timeout=(10, 1800),
|
||||
)
|
||||
except requests.RequestException:
|
||||
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
|
||||
@@ -3362,6 +3377,7 @@ def _format_shop_manage_item(item):
|
||||
'group_name': item.get('groupName') or '',
|
||||
'shop_name': item.get('shopName') or '',
|
||||
'mall_name': item.get('mallName') or '',
|
||||
'zn_username': item.get('znUsername') or '',
|
||||
'account': item.get('account') or '',
|
||||
'password': item.get('passwordMasked') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
@@ -3493,7 +3509,11 @@ def get_shop_manage_credential(item_id):
|
||||
credential = credential_result.get('data') or {}
|
||||
if str(credential.get('id')) != str(item_id):
|
||||
return jsonify({'success': False, 'error': '店铺凭据不匹配'}), 409
|
||||
response = jsonify({'success': True, 'password': credential.get('password') or ''})
|
||||
response = jsonify({
|
||||
'success': True,
|
||||
'zn_username': credential.get('znUsername') or '',
|
||||
'password': credential.get('password') or '',
|
||||
})
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
@@ -3509,6 +3529,7 @@ def create_shop_manage():
|
||||
'groupId': data.get('group_id'),
|
||||
'shopName': (data.get('shop_name') or '').strip(),
|
||||
'mallName': (data.get('mall_name') or '').strip(),
|
||||
'znUsername': (data.get('zn_username') or '').strip(),
|
||||
'account': (data.get('account') or '').strip(),
|
||||
'password': (data.get('password') or '').strip(),
|
||||
'createdById': current_row.get('id') if current_row else None,
|
||||
@@ -3534,6 +3555,7 @@ def create_shop_manage():
|
||||
'group_name': item.get('groupName') or '',
|
||||
'shop_name': item.get('shopName') or '',
|
||||
'mall_name': item.get('mallName') or '',
|
||||
'zn_username': item.get('znUsername') or '',
|
||||
'account': item.get('account') or '',
|
||||
'password': item.get('password') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
@@ -3556,6 +3578,8 @@ def update_shop_manage(item_id):
|
||||
'account': (data.get('account') or '').strip(),
|
||||
'password': (data.get('password') or '').strip(),
|
||||
}
|
||||
if 'zn_username' in data:
|
||||
payload['znUsername'] = (data.get('zn_username') or '').strip()
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/shop-manages/{item_id}',
|
||||
@@ -3576,6 +3600,7 @@ def update_shop_manage(item_id):
|
||||
'group_name': item.get('groupName') or '',
|
||||
'shop_name': item.get('shopName') or '',
|
||||
'mall_name': item.get('mallName') or '',
|
||||
'zn_username': item.get('znUsername') or '',
|
||||
'account': item.get('account') or '',
|
||||
'password': item.get('password') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
|
||||
+42
-7
@@ -2245,7 +2245,27 @@
|
||||
});
|
||||
}
|
||||
document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); };
|
||||
document.getElementById('btnExportDedupeTotalData').onclick = function () {
|
||||
var dedupeTotalDataExportButton = document.getElementById('btnExportDedupeTotalData');
|
||||
var dedupeTotalDataExportWait = document.getElementById('dedupeTotalDataExportWait');
|
||||
var dedupeTotalDataExportWaitSeconds = document.getElementById('dedupeTotalDataExportWaitSeconds');
|
||||
var dedupeTotalDataExportWaitTimer = null;
|
||||
function showDedupeTotalDataExportWait() {
|
||||
var startedAt = Date.now();
|
||||
dedupeTotalDataExportWaitSeconds.textContent = '0';
|
||||
dedupeTotalDataExportWait.classList.add('show');
|
||||
dedupeTotalDataExportWait.setAttribute('aria-hidden', 'false');
|
||||
dedupeTotalDataExportWaitTimer = setInterval(function () {
|
||||
dedupeTotalDataExportWaitSeconds.textContent = String(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
}
|
||||
function hideDedupeTotalDataExportWait() {
|
||||
clearInterval(dedupeTotalDataExportWaitTimer);
|
||||
dedupeTotalDataExportWaitTimer = null;
|
||||
dedupeTotalDataExportWait.classList.remove('show');
|
||||
dedupeTotalDataExportWait.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
dedupeTotalDataExportButton.onclick = function () {
|
||||
if (dedupeTotalDataExportButton.disabled) return;
|
||||
var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim();
|
||||
var groupId = (document.getElementById('dedupeTotalDataGroupFilterId').value || '').trim();
|
||||
var dateRange = getDedupeTotalDataDateRange();
|
||||
@@ -2255,6 +2275,11 @@
|
||||
if (groupId) params.push('group_id=' + encodeURIComponent(groupId));
|
||||
if (dateRange.startDate) params.push('start_date=' + encodeURIComponent(dateRange.startDate));
|
||||
if (dateRange.endDate) params.push('end_date=' + encodeURIComponent(dateRange.endDate));
|
||||
var originalButtonText = dedupeTotalDataExportButton.textContent;
|
||||
dedupeTotalDataExportButton.disabled = true;
|
||||
dedupeTotalDataExportButton.setAttribute('aria-busy', 'true');
|
||||
dedupeTotalDataExportButton.textContent = '导出中...';
|
||||
showDedupeTotalDataExportWait();
|
||||
fetch('/api/admin/dedupe-total-data/export' + (params.length ? ('?' + params.join('&')) : ''))
|
||||
.then(function (response) {
|
||||
var contentType = response.headers.get('content-type') || '';
|
||||
@@ -2278,6 +2303,12 @@
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert((err && err.message) || '导出失败');
|
||||
})
|
||||
.finally(function () {
|
||||
dedupeTotalDataExportButton.disabled = false;
|
||||
dedupeTotalDataExportButton.removeAttribute('aria-busy');
|
||||
dedupeTotalDataExportButton.textContent = originalButtonText;
|
||||
hideDedupeTotalDataExportWait();
|
||||
});
|
||||
};
|
||||
var dedupeImportPollTimer = null;
|
||||
@@ -2907,16 +2938,16 @@
|
||||
.then(function (res) {
|
||||
var tbody = document.getElementById('shopManageListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-tip">暂无店铺</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">暂无店铺</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function (item, index) {
|
||||
var rowNo = (shopManagePage - 1) * shopManagePageSize + index + 1;
|
||||
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.account || '') + '</td><td>' + renderShopPasswordCell(item) + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
|
||||
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.zn_username || '') + '</td><td>' + (item.account || '') + '</td><td>' + renderShopPasswordCell(item) + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-shop-manage-edit="' + item.id + '" data-shop-manage="' + (JSON.stringify(item).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-shop-manage-delete="' + item.id + '" data-shop-manage-name="' + (item.shop_name || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</td></tr>';
|
||||
@@ -2926,7 +2957,7 @@
|
||||
bindShopManageActions();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('shopManageListBody').innerHTML = '<tr><td colspan="9" class="empty-tip">请求失败</td></tr>';
|
||||
document.getElementById('shopManageListBody').innerHTML = '<tr><td colspan="10" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2974,6 +3005,7 @@
|
||||
document.getElementById('editShopManageId').value = item.id || '';
|
||||
document.getElementById('editShopManageShopName').value = item.shop_name || '';
|
||||
document.getElementById('editShopManageMallName').value = item.mall_name || '';
|
||||
document.getElementById('editShopManageZnUsername').value = item.zn_username || '';
|
||||
document.getElementById('editShopManageAccount').value = item.account || '';
|
||||
document.getElementById('editShopManagePassword').value = item.password || '';
|
||||
document.getElementById('msgEditShopManage').textContent = '';
|
||||
@@ -3367,6 +3399,7 @@
|
||||
var groupId = (document.getElementById('shopManageGroupSelect').value || '').trim();
|
||||
var shopName = (document.getElementById('shopManageShopName').value || '').trim();
|
||||
var mallName = (document.getElementById('shopManageMallName').value || '').trim();
|
||||
var znUsername = (document.getElementById('shopManageZnUsername').value || '').trim();
|
||||
var account = (document.getElementById('shopManageAccount').value || '').trim();
|
||||
var password = (document.getElementById('shopManagePassword').value || '').trim();
|
||||
var msgEl = document.getElementById('msgShopManage');
|
||||
@@ -3380,7 +3413,7 @@
|
||||
fetch('/api/admin/shop-manage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password })
|
||||
body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, zn_username: znUsername, account: account, password: password })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
@@ -3388,6 +3421,7 @@
|
||||
document.getElementById('shopManageGroupSelect').value = '';
|
||||
document.getElementById('shopManageShopName').value = '';
|
||||
document.getElementById('shopManageMallName').value = '';
|
||||
document.getElementById('shopManageZnUsername').value = '';
|
||||
document.getElementById('shopManageAccount').value = '';
|
||||
document.getElementById('shopManagePassword').value = '';
|
||||
msgEl.textContent = res.msg || '创建成功';
|
||||
@@ -3409,6 +3443,7 @@
|
||||
var groupId = (document.getElementById('editShopManageGroupSelect').value || '').trim();
|
||||
var shopName = (document.getElementById('editShopManageShopName').value || '').trim();
|
||||
var mallName = (document.getElementById('editShopManageMallName').value || '').trim();
|
||||
var znUsername = (document.getElementById('editShopManageZnUsername').value || '').trim();
|
||||
var account = (document.getElementById('editShopManageAccount').value || '').trim();
|
||||
var password = (document.getElementById('editShopManagePassword').value || '').trim();
|
||||
var msgEl = document.getElementById('msgEditShopManage');
|
||||
@@ -3422,7 +3457,7 @@
|
||||
fetch('/api/admin/shop-manage/' + itemId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password })
|
||||
body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, zn_username: znUsername, account: account, password: password })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
|
||||
@@ -152,6 +152,7 @@ class AdminDedupeTotalDataTest(unittest.TestCase):
|
||||
})
|
||||
self.assertEqual(session.kwargs['headers'], {'X-Internal-Token': 'token'})
|
||||
self.assertTrue(session.kwargs['stream'])
|
||||
self.assertEqual(session.kwargs['timeout'], (10, 1800))
|
||||
|
||||
def test_import_requires_group(self):
|
||||
with self.app.test_request_context(
|
||||
|
||||
@@ -291,6 +291,52 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dedupe-export-wait-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3100;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(17, 24, 39, 0.38);
|
||||
}
|
||||
|
||||
.dedupe-export-wait-mask.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dedupe-export-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dedupe-export-wait .request-spinner {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.dedupe-export-wait-title {
|
||||
margin-bottom: 4px;
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dedupe-export-wait-detail {
|
||||
color: #687386;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.request-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -1721,6 +1767,18 @@
|
||||
<span class="request-spinner"></span>
|
||||
<span>请求处理中...</span>
|
||||
</div>
|
||||
<div class="dedupe-export-wait-mask" id="dedupeTotalDataExportWait" role="dialog" aria-modal="true"
|
||||
aria-labelledby="dedupeTotalDataExportWaitTitle" aria-hidden="true">
|
||||
<div class="dedupe-export-wait">
|
||||
<span class="request-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="dedupe-export-wait-title" id="dedupeTotalDataExportWaitTitle">正在生成导出文件</div>
|
||||
<div class="dedupe-export-wait-detail">
|
||||
数据量较大,请耐心等待并保持页面打开。已等待 <span id="dedupeTotalDataExportWaitSeconds">0</span> 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1>管理后台</h1>
|
||||
|
||||
<div class="admin-user-box" style="position:absolute;top:24px;right:24px;z-index:10;">
|
||||
@@ -2188,6 +2246,10 @@
|
||||
<label>店铺商城名</label>
|
||||
<input type="text" id="shopManageMallName" placeholder="请输入店铺商城名">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>自动化账号</label>
|
||||
<input type="text" id="shopManageZnUsername" maxlength="128" placeholder="请输入自动化账号(可选)">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>账号</label>
|
||||
<input type="text" id="shopManageAccount" placeholder="请输入账号">
|
||||
@@ -2222,6 +2284,7 @@
|
||||
<th>分组</th>
|
||||
<th>店铺名</th>
|
||||
<th>店铺商城名</th>
|
||||
<th>自动化账号</th>
|
||||
<th>账号</th>
|
||||
<th>密码</th>
|
||||
<th>创建时间</th>
|
||||
@@ -3003,6 +3066,10 @@
|
||||
<label>店铺商城名</label>
|
||||
<input type="text" id="editShopManageMallName" placeholder="店铺商城名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>自动化账号</label>
|
||||
<input type="text" id="editShopManageZnUsername" maxlength="128" placeholder="自动化账号(可选)">
|
||||
</div>
|
||||
<div class=" form-group">
|
||||
<label>账号</label>
|
||||
<input type="text" id="editShopManageAccount" placeholder="账号">
|
||||
@@ -3108,7 +3175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/admin.js?v=shop-data-task-admin-2"></script>
|
||||
<script src="/static/admin.js?v=dedupe-export-wait-1"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -170,7 +170,7 @@ const TaskRow = defineComponent({
|
||||
props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null,
|
||||
]),
|
||||
h('div', { class: 'task-right' }, [
|
||||
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item.taskStatus)),
|
||||
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item)),
|
||||
canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null,
|
||||
h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'),
|
||||
]),
|
||||
@@ -226,7 +226,13 @@ function countryLabel(code: string) { return COUNTRY_OPTIONS.find((row) => row.c
|
||||
function isCountrySelected(code: string) { return orderedCountryCodes.value.includes(code) }
|
||||
function isCountrySelectionLocked(code: string) { return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code }
|
||||
function isTerminal(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' }
|
||||
function statusText(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : status === 'FAILED' ? '失败' : '执行中' }
|
||||
function statusText(item: ShopDataCrawlHistoryItem) {
|
||||
const status = item.taskStatus
|
||||
if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'
|
||||
if (status === 'FAILED') return '失败'
|
||||
if (item.success === true) return '结果文件生成中'
|
||||
return 'Python 处理中'
|
||||
}
|
||||
function statusClass(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
||||
function canDownload(item: ShopDataCrawlHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) }
|
||||
function formatDateTime(value?: string) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) }
|
||||
@@ -596,7 +602,7 @@ onUnmounted(() => {
|
||||
:deep(.id) { display: block; margin-bottom: 5px; color: #eee; font-weight: 700; }
|
||||
:deep(.files) { margin-top: 3px; color: #888; font-size: 12px; }
|
||||
:deep(.task-right) { display: flex; align-items: center; gap: 10px; }
|
||||
:deep(.status) { min-width: 52px; font-size: 12px; text-align: center; }
|
||||
:deep(.status) { min-width: 88px; font-size: 12px; text-align: center; white-space: nowrap; }
|
||||
:deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; }
|
||||
:deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #ccc; cursor: pointer; }
|
||||
:deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }
|
||||
|
||||
Reference in New Issue
Block a user