优化后台业务

This commit is contained in:
super
2026-04-30 11:36:58 +08:00
parent 9c16c9c589
commit c6ecfb5b77
48 changed files with 1211 additions and 2391 deletions

View File

@@ -25,8 +25,19 @@
<hutool.version>5.8.36</hutool.version>
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
<minio.version>8.5.17</minio.version>
<javassist.version>3.28.0-GA</javassist.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>${javassist.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -649,7 +649,7 @@ public class AppearancePatentCozeClient {
}
private String normalize(String value) {
return value == null ? "" : value.replace("\ufeff", "").replace("\u3000", " ").trim();
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
}
private String rowKey(AppearancePatentResultRowDto row) {

View File

@@ -83,7 +83,10 @@ public class AppearancePatentController {
public ApiResponse<Void> result(
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
@PathVariable Long taskId,
@Valid @RequestBody AppearancePatentSubmitResultRequest request) {
@Valid @RequestBody AppearancePatentSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
service.submitResult(taskId, request);
return ApiResponse.success(null);
}

View File

@@ -121,7 +121,7 @@ public class AppearancePatentTaskService {
.filter(Objects::nonNull)
.toList();
if (sourceFiles.isEmpty()) {
throw new BusinessException("璇峰厛涓婁紶 Excel 鏂囦欢");
throw new BusinessException("请先上传 Excel 文件");
}
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
List<String> mergedHeaders = new ArrayList<>();
@@ -461,10 +461,10 @@ public class AppearancePatentTaskService {
private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("浠诲姟涓嶅瓨鍦?");
throw new BusinessException("任务不存在");
}
if (!STATUS_RUNNING.equals(task.getStatus())) {
throw new BusinessException("浠诲姟涓嶆槸杩愯涓姸鎬?");
throw new BusinessException("任务不是运行中状态");
}
int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex();
@@ -482,7 +482,7 @@ public class AppearancePatentTaskService {
.last("limit 1"));
if (existing == null) {
List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request);
String payloadJson = writeJson(rawRows, "缁撴灉搴忓垪鍖栧け璐?");
String payloadJson = writeJson(rawRows, "结果序列化失败");
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setTaskId(taskId);
@@ -514,7 +514,7 @@ public class AppearancePatentTaskService {
private void completeSubmittedChunk(SubmitContext context) {
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("浠诲姟涓嶅瓨鍦?");
throw new BusinessException("任务不存在");
}
if (!STATUS_RUNNING.equals(task.getStatus())) {
log.info("[appearance-patent] skip completion because task already finalized taskId={} status={}",
@@ -1379,7 +1379,7 @@ public class AppearancePatentTaskService {
}
private String normalize(String val) {
return val == null ? "" : val.replace("\ufeff", "").replace("\u3000", " ").trim().replaceAll("\\s+", " ");
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
}
private String buildParsedPayloadJson(String aiPrompt, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedGroupVo> groups, List<AppearancePatentParsedRowVo> allRows) {

View File

@@ -881,8 +881,8 @@ public class BrandTaskService {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
return value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")

View File

@@ -475,8 +475,8 @@ public class ConvertRunService {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
return value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")

View File

@@ -47,8 +47,8 @@ import java.util.zip.ZipOutputStream;
@Slf4j
public class DedupeRunService {
private static final String HEADER_SPLIT_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf";
private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408";
private static final String HEADER_SPLIT_MARKER = "idASIN国家状态价格变体数量";
private static final String HEADER_STOP_COLUMN = "缩略图地址8";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
@@ -59,7 +59,7 @@ public class DedupeRunService {
public DedupeRunVo run(DedupeRunRequest request) {
long runStartNs = System.nanoTime();
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) {
throw new BusinessException("\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u79cd ID \u4fdd\u7559\u89c4\u5219");
throw new BusinessException("请至少选择一种 ID 保留规则");
}
FileTaskEntity task = new FileTaskEntity();
@@ -90,7 +90,7 @@ public class DedupeRunService {
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
long findFileNs = elapsedNs(fileStartNs);
if (inputFile == null || !inputFile.exists()) {
throw new BusinessException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u5b58\u5728\uff0c\u8bf7\u91cd\u65b0\u4e0a\u4f20");
throw new BusinessException("上传文件不存在,请重新上传");
}
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
@@ -240,7 +240,7 @@ public class DedupeRunService {
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728");
throw new BusinessException("记录不存在");
}
fileResultMapper.deleteById(resultId);
}
@@ -248,11 +248,11 @@ public class DedupeRunService {
public Resource getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
throw new BusinessException("结果文件不存在");
}
File file = new File(entity.getResultFileUrl());
if (!file.exists()) {
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
throw new BusinessException("结果文件不存在");
}
return new FileSystemResource(file);
}
@@ -342,18 +342,18 @@ public class DedupeRunService {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
throw new BusinessException("Excel 表头为空");
}
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
if (headerMap.isEmpty()) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
throw new BusinessException("Excel 表头为空");
}
List<String> missing = selectedColumns.stream()
.filter(column -> !headerMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("\u7f3a\u5c11\u5217\uff1a" + String.join("\u3001", missing));
throw new BusinessException("缺少列:" + String.join("", missing));
}
List<Integer> selectedIndexes = new ArrayList<>(selectedColumns.size());
@@ -478,7 +478,7 @@ public class DedupeRunService {
zos.closeEntry();
}
} catch (Exception ex) {
throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage());
throw new BusinessException("去重结果打包失败:" + ex.getMessage());
}
return zipFile;
}
@@ -602,7 +602,7 @@ public class DedupeRunService {
boolean previousWhitespace = false;
for (int i = start; i < end; i++) {
char ch = value.charAt(i);
if (ch == '\uFEFF') {
if (ch == 0xFEFF) {
if (builder == null) {
builder = new StringBuilder(value.length());
builder.append(value, start, i);
@@ -629,11 +629,11 @@ public class DedupeRunService {
}
private boolean isTrimmedWhitespace(char ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch);
}
private boolean isNormalizedWhitespace(char ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch);
}
private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) {

View File

@@ -109,8 +109,8 @@ public class LocalFileStorageService {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
return value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")

View File

@@ -281,7 +281,6 @@ public class PatrolDeleteTaskService {
return vo;
}
@Transactional
public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
@@ -334,7 +333,6 @@ public class PatrolDeleteTaskService {
tryFinalizeTask(taskId, false);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -88,6 +89,10 @@ public class PriceTrackSubmitResultRequest {
@Schema(description = "购物车店铺名", example = "店铺A")
private String cartShopName;
@JsonAlias({"changedPrice", "changePrice", "modifiedPrice", "newPrice", "targetPrice", "updatedPrice", "改价价格"})
@Schema(description = "改价价格", example = "18.99")
private String priceChangePrice;
@Schema(description = "改价情况", example = "UPDATED")
private String priceChangeStatus;

View File

@@ -29,6 +29,7 @@ public class PriceTrackExcelAssemblyService {
"第一名",
"第二名",
"购物车店铺名",
"改价价格",
"改价情况",
"修改次数",
"状态"
@@ -40,13 +41,14 @@ public class PriceTrackExcelAssemblyService {
workbook.setCompressTempFiles(true);
try (FileOutputStream fos = new FileOutputStream(outputXlsx)) {
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
String sheetName = code.name();
String countryCode = code.name();
String sheetName = sheetNameOf(code);
Sheet sheet = workbook.createSheet(sheetName);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < RESULT_HEADER.length; i++) {
headerRow.createCell(i).setCellValue(RESULT_HEADER[i]);
}
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(countryCode, List.of());
int rowIdx = 1;
for (PriceTrackSubmitResultRequest.AsinResult item : rows) {
if (item == null) {
@@ -62,9 +64,10 @@ public class PriceTrackExcelAssemblyService {
row.createCell(6).setCellValue(valueOf(item.getFirstPlace()));
row.createCell(7).setCellValue(valueOf(item.getSecondPlace()));
row.createCell(8).setCellValue(valueOf(item.getCartShopName()));
row.createCell(9).setCellValue(valueOf(item.getPriceChangeStatus()));
row.createCell(10).setCellValue(valueOf(item.getModifyCount()));
row.createCell(11).setCellValue(valueOf(item.getStatus()));
row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice()));
row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus()));
row.createCell(11).setCellValue(valueOf(item.getModifyCount()));
row.createCell(12).setCellValue(valueOf(item.getStatus()));
}
applyDefaultColumnWidths(sheet);
}
@@ -113,8 +116,18 @@ public class PriceTrackExcelAssemblyService {
return value == null ? "" : value;
}
private static String sheetNameOf(ProductRiskCountryCode code) {
return switch (code) {
case DE -> "德国";
case FR -> "法国";
case ES -> "西班牙";
case IT -> "意大利";
case UK -> "英国";
};
}
private void applyDefaultColumnWidths(Sheet sheet) {
int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 18, 12, 12};
int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 14, 18, 12, 12};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256);
}

View File

@@ -127,6 +127,21 @@ public class PriceTrackTaskService {
return tasks;
}
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
}
return tasks;
}
public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
PriceTrackDashboardVo vo = new PriceTrackDashboardVo();
@@ -166,7 +181,7 @@ public class PriceTrackTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) {
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
}
@@ -383,7 +398,6 @@ public class PriceTrackTaskService {
return vo;
}
@Transactional
public void submitResult(Long taskId, PriceTrackSubmitResultRequest request) {
if (taskId == null || taskId <= 0) throw new BusinessException("taskId 不合法");
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
@@ -458,7 +472,6 @@ public class PriceTrackTaskService {
updateTaskStatusFromLatestRows(task, latest);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) return false;
FileTaskEntity task = loadTaskForExecution(taskId);
@@ -1139,6 +1152,7 @@ public class PriceTrackTaskService {
merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace()));
merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace()));
merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName()));
merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice()));
merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus()));
merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount()));
merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus()));
@@ -1163,6 +1177,7 @@ public class PriceTrackTaskService {
out.setFirstPlace(row.getFirstPlace());
out.setSecondPlace(row.getSecondPlace());
out.setCartShopName(row.getCartShopName());
out.setPriceChangePrice(row.getPriceChangePrice());
out.setPriceChangeStatus(row.getPriceChangeStatus());
out.setModifyCount(row.getModifyCount());
out.setStatus(row.getStatus());

View File

@@ -124,6 +124,21 @@ public class ProductRiskTaskService {
return tasks;
}
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
@@ -166,7 +181,7 @@ public class ProductRiskTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) {
if (t != null) {
statusByTaskId.put(t.getId(), t.getStatus());
@@ -465,7 +480,6 @@ public class ProductRiskTaskService {
return vo;
}
@Transactional
public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
@@ -630,7 +644,6 @@ public class ProductRiskTaskService {
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;

View File

@@ -287,7 +287,6 @@ public class QueryAsinTaskService {
return vo;
}
@Transactional
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
@@ -340,7 +339,6 @@ public class QueryAsinTaskService {
tryFinalizeTask(taskId, false);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;

View File

@@ -3,7 +3,68 @@ package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity> {
@Select("""
SELECT DISTINCT accessible_group_id
FROM (
SELECT g.id AS accessible_group_id
FROM biz_shop_manage_group g
WHERE g.created_by_id = #{operatorId}
OR g.user_id = #{operatorId}
UNION
SELECT gm.group_id AS accessible_group_id
FROM biz_shop_manage_group_member gm
WHERE gm.user_id = #{operatorId}
UNION
SELECT g2.id AS accessible_group_id
FROM biz_shop_manage_group_member gm
INNER JOIN biz_shop_manage_group g1 ON g1.id = gm.group_id
INNER JOIN biz_shop_manage_group g2
ON g2.created_by_id = COALESCE(g1.created_by_id, g1.user_id)
OR g2.user_id = COALESCE(g1.created_by_id, g1.user_id)
WHERE gm.user_id = #{operatorId}
AND COALESCE(g1.created_by_id, g1.user_id) IS NOT NULL
UNION
SELECT member_groups.id AS accessible_group_id
FROM biz_shop_manage_group leader_group
INNER JOIN biz_shop_manage_group_member direct_member ON direct_member.group_id = leader_group.id
INNER JOIN biz_shop_manage_group member_groups
ON member_groups.created_by_id = direct_member.user_id
OR member_groups.user_id = direct_member.user_id
WHERE leader_group.created_by_id = #{operatorId}
OR leader_group.user_id = #{operatorId}
) accessible_groups
WHERE accessible_group_id IS NOT NULL
ORDER BY accessible_group_id ASC
""")
List<Long> selectAccessibleGroupIds(@Param("operatorId") Long operatorId);
@Select("""
SELECT DISTINCT visible_user_id
FROM (
SELECT #{operatorId} AS visible_user_id
UNION
SELECT direct_member.user_id AS visible_user_id
FROM biz_shop_manage_group leader_group
INNER JOIN biz_shop_manage_group_member direct_member ON direct_member.group_id = leader_group.id
WHERE leader_group.created_by_id = #{operatorId}
OR leader_group.user_id = #{operatorId}
UNION
SELECT COALESCE(direct_group.created_by_id, direct_group.user_id) AS visible_user_id
FROM biz_shop_manage_group_member membership
INNER JOIN biz_shop_manage_group direct_group ON direct_group.id = membership.group_id
WHERE membership.user_id = #{operatorId}
AND COALESCE(direct_group.created_by_id, direct_group.user_id) IS NOT NULL
) visible_users
WHERE visible_user_id IS NOT NULL
ORDER BY visible_user_id ASC
""")
List<Long> selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId);
}

View File

@@ -40,7 +40,7 @@ public class QueryAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final String KEY_SEPARATOR = Character.toString((char) 1);
private static final String UTF8_BOM = Character.toString(0xFEFF);
private static final String UTF8_BOM = String.valueOf((char) 0xFEFF);
private final QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService;
@@ -79,7 +79,7 @@ public class QueryAsinService {
List<QueryAsinEntity> rows = queryAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
@@ -316,22 +316,6 @@ public class QueryAsinService {
return vo;
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) {
@@ -384,11 +368,11 @@ public class QueryAsinService {
return upper;
}
return switch (normalizeHeaderKey(normalized)) {
case "\u5fb7\u56fd", "\u5fb7", "de", "germany", "german", "deutschland" -> "DE";
case "\u82f1\u56fd", "\u82f1", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
case "\u6cd5\u56fd", "\u6cd5", "fr", "france", "french" -> "FR";
case "\u610f\u5927\u5229", "\u610f", "it", "italy", "italian" -> "IT";
case "\u897f\u73ed\u7259", "\u897f", "es", "spain", "spanish", "espana" -> "ES";
case "德国", "", "de", "germany", "german", "deutschland" -> "DE";
case "英国", "", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
case "法国", "", "fr", "france", "french" -> "FR";
case "意大利", "", "it", "italy", "italian" -> "IT";
case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES";
default -> "";
};
}
@@ -860,11 +844,11 @@ public class QueryAsinService {
if (index != null) {
return index;
}
index = firstHeader("groupname", "\u5206\u7ec4id", "\u5206\u7ec4");
index = firstHeader("groupname", "分组id", "分组");
if (index != null) {
return index;
}
return findHeaderByPredicate(key -> key.contains("\u5206\u7ec4") || key.contains("group"));
return findHeaderByPredicate(key -> key.contains("分组") || key.contains("group"));
}
private Integer resolveShopHeaderIndex() {
@@ -873,13 +857,13 @@ public class QueryAsinService {
return index;
}
index = firstHeader(
"\u5e97\u94fa\u540d", "\u5e97\u94fa\u540d\u79f0", "\u5e97\u94fa\u8d26\u53f7", "\u8d26\u53f7",
"店铺名", "店铺名称", "店铺账号", "账号",
"account", "selleraccount", "storeaccount", "storename");
if (index != null) {
return index;
}
return findHeaderByPredicate(key ->
(key.contains("\u5e97\u94fa") && (key.contains("\u540d") || key.contains("\u8d26\u53f7")))
(key.contains("店铺") && (key.contains("") || key.contains("账号")))
|| (key.contains("shop") && (key.contains("name") || key.contains("account")))
|| key.contains("selleraccount")
|| key.contains("storeaccount"));
@@ -891,32 +875,32 @@ public class QueryAsinService {
return index;
}
return switch (country) {
case "DE" -> firstHeader("\u5fb7\u56fd", "\u5fb7", "\u5fb7\u56fdasin");
case "UK" -> firstHeader("\u82f1\u56fd", "\u82f1", "\u82f1\u56fdasin");
case "FR" -> firstHeader("\u6cd5\u56fd", "\u6cd5", "\u6cd5\u56fdasin");
case "IT" -> firstHeader("\u610f\u5927\u5229", "\u610f", "\u610f\u5927\u5229asin");
case "ES" -> firstHeader("\u897f\u73ed\u7259", "\u897f", "\u897f\u73ed\u7259asin");
case "DE" -> firstHeader("德国", "", "德国asin");
case "UK" -> firstHeader("英国", "", "英国asin");
case "FR" -> firstHeader("法国", "", "法国asin");
case "IT" -> firstHeader("意大利", "", "意大利asin");
case "ES" -> firstHeader("西班牙", "西", "西班牙asin");
default -> null;
};
}
private Integer findSingleCountryIndex() {
Integer index = firstHeader(
"\u4e2d\u6587\u56fd\u5bb6\u540d", "\u56fd\u5bb6", "\u56fd\u5bb6\u540d", "\u56fd\u5bb6\u7ad9\u70b9",
"\u7ad9\u70b9", "country", "countryname", "marketplace", "site");
"中文国家名", "国家", "国家名", "国家站点",
"站点", "country", "countryname", "marketplace", "site");
if (index != null) {
return index;
}
return findHeaderByPredicate(key ->
key.contains("\u56fd\u5bb6")
|| key.contains("\u7ad9\u70b9")
key.contains("国家")
|| key.contains("站点")
|| key.contains("country")
|| key.contains("marketplace")
|| key.equals("site"));
}
private Integer findSingleAsinIndex() {
return firstHeader("asin", "\u67e5\u8be2asin", "\u4ea7\u54c1asin", "\u94fe\u63a5asin");
return firstHeader("asin", "查询asin", "产品asin", "链接asin");
}
private boolean hasWideCountryColumns() {

View File

@@ -48,6 +48,7 @@ public class ShopManageGroupService {
public List<ShopManageGroupItemVo> list(Long createdById) {
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
.or(createdById != null && createdById > 0, wrapper -> wrapper.eq(ShopManageGroupEntity::getUserId, createdById))
.orderByAsc(ShopManageGroupEntity::getId)));
}
@@ -74,40 +75,44 @@ public class ShopManageGroupService {
.collect(Collectors.toCollection(LinkedHashSet::new));
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
LinkedHashSet<Long> groupIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId)
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId))
return groupMapper.selectAccessibleGroupIds(normalizedOperatorId)
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<Long> memberGroupIds = groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.select(ShopManageGroupMemberEntity::getGroupId)
.eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId))
.stream()
.map(ShopManageGroupMemberEntity::getGroupId)
.filter(id -> id != null && id > 0)
.toList();
groupIds.addAll(memberGroupIds);
if (!memberGroupIds.isEmpty()) {
LinkedHashSet<Long> leaderIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getCreatedById)
.in(ShopManageGroupEntity::getId, memberGroupIds))
.stream()
.map(ShopManageGroupEntity::getCreatedById)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (!leaderIds.isEmpty()) {
groupIds.addAll(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId)
.in(ShopManageGroupEntity::getCreatedById, leaderIds))
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.toList());
}
}
public Set<Long> listAccessibleCreatorUserIds(Long operatorId, boolean superAdmin) {
if (superAdmin) {
return Set.of();
}
return groupIds;
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectAccessibleCreatorUserIds(normalizedOperatorId)
.stream()
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
List<Long> normalizedGroupIds = groupIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalizedGroupIds.isEmpty()) {
return Map.of();
}
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId, ShopManageGroupEntity::getGroupName)
.in(ShopManageGroupEntity::getId, normalizedGroupIds))
.stream()
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
ShopManageGroupEntity::getId,
group -> normalizeBlank(group.getGroupName()),
(left, right) -> left,
LinkedHashMap::new));
}
@Transactional
@@ -207,8 +212,9 @@ public class ShopManageGroupService {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
if (!createdBySelf) {
boolean leaderBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
boolean legacyLeaderBySelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
if (!leaderBySelf && !legacyLeaderBySelf) {
throw new BusinessException("只有组长才能维护分组");
}
}
@@ -332,8 +338,9 @@ public class ShopManageGroupService {
LinkedHashSet<Long> userIds = new LinkedHashSet<>();
for (ShopManageGroupEntity group : groups) {
if (group.getCreatedById() != null && group.getCreatedById() > 0) {
userIds.add(group.getCreatedById());
Long leaderUserId = resolveGroupLeaderUserId(group);
if (leaderUserId != null && leaderUserId > 0) {
userIds.add(leaderUserId);
}
}
for (List<ShopManageGroupMemberEntity> members : membersByGroupId.values()) {
@@ -355,8 +362,9 @@ public class ShopManageGroupService {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId());
vo.setGroupName(entity.getGroupName());
vo.setLeaderUserId(entity.getCreatedById());
AdminUserEntity leaderUser = entity.getCreatedById() == null ? null : userById.get(entity.getCreatedById());
Long leaderUserId = resolveGroupLeaderUserId(entity);
vo.setLeaderUserId(leaderUserId);
AdminUserEntity leaderUser = leaderUserId == null ? null : userById.get(leaderUserId);
vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername()));
List<ShopManageGroupMemberEntity> memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList());
@@ -380,4 +388,14 @@ public class ShopManageGroupService {
}
return result;
}
private Long resolveGroupLeaderUserId(ShopManageGroupEntity entity) {
if (entity == null) {
return null;
}
if (entity.getCreatedById() != null && entity.getCreatedById() > 0) {
return entity.getCreatedById();
}
return entity.getUserId();
}
}

View File

@@ -15,7 +15,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -33,6 +32,7 @@ public class ShopManageService {
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = shopName == null ? "" : shopName.trim();
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
Set<Long> accessibleCreatorUserIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleCreatorUserIds(operatorId, false);
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
@@ -40,20 +40,30 @@ public class ShopManageService {
.orderByDesc(ShopManageEntity::getId);
if (!superAdmin) {
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
query.and(wrapper -> {
wrapper.eq(ShopManageEntity::getCreatedById, normalizedOperatorId);
if (!accessibleGroupIds.isEmpty()) {
wrapper.or().in(ShopManageEntity::getGroupId, accessibleGroupIds);
}
});
normalizePositiveId(operatorId, "操作人不能为空");
if (accessibleGroupIds.isEmpty() && accessibleCreatorUserIds.isEmpty()) {
query.eq(ShopManageEntity::getId, -1L);
} else {
query.and(wrapper -> {
boolean hasCreatorFilter = !accessibleCreatorUserIds.isEmpty();
if (hasCreatorFilter) {
wrapper.in(ShopManageEntity::getCreatedById, accessibleCreatorUserIds);
}
if (!accessibleGroupIds.isEmpty()) {
if (hasCreatorFilter) {
wrapper.or();
}
wrapper.in(ShopManageEntity::getGroupId, accessibleGroupIds);
}
});
}
}
Long total = shopManageMapper.selectCount(query);
List<ShopManageEntity> rows = shopManageMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(ShopManageEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
@@ -190,22 +200,6 @@ public class ShopManageService {
}
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private ShopManageItemVo toItemVo(ShopManageEntity entity, String groupName) {
ShopManageItemVo vo = new ShopManageItemVo();
vo.setId(entity.getId());

View File

@@ -62,7 +62,7 @@ public class SkipPriceAsinService {
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
@@ -174,22 +174,6 @@ public class SkipPriceAsinService {
return vo;
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) {

View File

@@ -132,6 +132,21 @@ public class ShopMatchTaskService {
return tasks;
}
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
@@ -174,7 +189,7 @@ public class ShopMatchTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) {
if (task != null) {
statusByTaskId.put(task.getId(), task.getStatus());
}
@@ -465,7 +480,6 @@ public class ShopMatchTaskService {
}
@Transactional
public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
@@ -540,7 +554,6 @@ public class ShopMatchTaskService {
updateTaskStatusFromLatestRows(task, latest);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;

View File

@@ -456,8 +456,8 @@ public class SplitRunService {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
return value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")

View File

@@ -23,7 +23,6 @@ public class TaskFileJobService {
private final TaskFileJobMapper taskFileJobMapper;
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) {
if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) {
return null;
@@ -146,7 +145,6 @@ public class TaskFileJobService {
return reset;
}
@Transactional
public boolean markRunning(Long jobId) {
if (jobId == null || jobId <= 0) {
return false;
@@ -158,7 +156,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0;
}
@Transactional
public void touchRunning(Long jobId) {
if (jobId == null || jobId <= 0) {
return;
@@ -169,7 +166,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
}
@Transactional
public void markSuccess(TaskFileJobEntity job, String resultFileUrl) {
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, job.getId())
@@ -180,7 +176,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
}
@Transactional
public void markFailed(TaskFileJobEntity job, String message) {
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()

View File

@@ -25,7 +25,6 @@ public class TaskResultItemService {
private final ObjectMapper objectMapper;
private final TransientPayloadStorageService transientPayloadStorageService;
@Transactional
public void replaceResultSnapshot(Long taskId, String moduleType, Long resultId, String scopeKey, Object snapshot) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0 || snapshot == null) {
return;
@@ -35,7 +34,6 @@ public class TaskResultItemService {
upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot);
}
@Transactional
public void replaceTaskSnapshots(Long taskId, String moduleType, List<? extends Object> snapshots, SnapshotKeyResolver resolver) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) {
return;

View File

@@ -25,7 +25,6 @@ public class TaskResultPayloadService {
private final ObjectMapper objectMapper;
private final TransientPayloadStorageService transientPayloadStorageService;
@Transactional
public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) {
return;

View File

@@ -212,7 +212,7 @@ public boolean isIpWhitelistError(BusinessException ex) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
return value.replace((char) 0x3000, ' ')
.trim();
}

View File

@@ -357,7 +357,7 @@ public class ZiniaoShopIndexService {
if (value == null) {
return "";
}
return value.replace("\u3000", " ").trim();
return value.replace((char) 0x3000, ' ').trim();
}
private ZiniaoShopMatchResultVo pendingResult(String message) {

View File

@@ -19,6 +19,10 @@ spring:
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
connection-timeout: ${AIIMAGE_DB_POOL_CONNECTION_TIMEOUT_MS:30000}
validation-timeout: ${AIIMAGE_DB_POOL_VALIDATION_TIMEOUT_MS:5000}
idle-timeout: ${AIIMAGE_DB_POOL_IDLE_TIMEOUT_MS:600000}
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:0}
data:
redis:
@@ -54,7 +58,7 @@ mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
springdoc:
api-docs:

View File

@@ -0,0 +1,61 @@
SET @db_name = DATABASE();
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage'
AND INDEX_NAME = 'idx_group_id_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_shop_manage ADD INDEX idx_group_id_id (group_id, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage'
AND INDEX_NAME = 'idx_created_by_id_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_shop_manage ADD INDEX idx_created_by_id_id (created_by_id, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_skip_price_asin'
AND INDEX_NAME = 'idx_group_id_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_id_id (group_id, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_query_asin'
AND INDEX_NAME = 'idx_group_id_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_query_asin ADD INDEX idx_group_id_id (group_id, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,31 @@
SET @db_name = DATABASE();
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'columns'
AND INDEX_NAME = 'idx_menu_type_sort_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE columns ADD INDEX idx_menu_type_sort_id (menu_type, sort_order, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'user_column_permission'
AND INDEX_NAME = 'idx_user_column'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE user_column_permission ADD INDEX idx_user_column (user_id, column_id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,31 @@
SET @db_name = DATABASE();
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_file_result'
AND INDEX_NAME = 'idx_file_result_task_module_id'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_file_result ADD INDEX idx_file_result_task_module_id (task_id, module_type, id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_task_file_job'
AND INDEX_NAME = 'idx_file_job_status_retry_updated'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_task_file_job ADD INDEX idx_file_job_status_retry_updated (status, retry_count, updated_at)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -4,9 +4,11 @@
import json
import os
import re
import threading
import requests
from flask import Blueprint, request, jsonify, session, current_app
from requests.adapters import HTTPAdapter
from flask import Blueprint, request, jsonify, session, current_app, g
import pymysql
from werkzeug.security import generate_password_hash
@@ -43,6 +45,9 @@ except ImportError:
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
_backend_java_session_local = threading.local()
_column_sort_schema_checked = False
_column_sort_schema_lock = threading.Lock()
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
@@ -103,10 +108,22 @@ def _safe_version_key(version):
return s or 'unknown'
def _get_backend_java_session():
http_session = getattr(_backend_java_session_local, 'session', None)
if http_session is None:
http_session = requests.Session()
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
http_session.mount('http://', adapter)
http_session.mount('https://', adapter)
_backend_java_session_local.session = http_session
return http_session
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
url = f"{backend_java_base_url}{path}"
try:
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
requester = requests if files else _get_backend_java_session()
resp = requester.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
except requests.RequestException:
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
try:
@@ -146,6 +163,28 @@ def _parse_optional_int(value):
def _ensure_column_sort_schema():
global _column_sort_schema_checked
if _column_sort_schema_checked:
return
with _column_sort_schema_lock:
if _column_sort_schema_checked:
return
conn = get_db()
try:
with conn.cursor() as cur:
try:
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '鑿滃崟鎺掑簭' AFTER route_path")
except Exception:
pass
try:
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
except Exception:
pass
conn.commit()
_column_sort_schema_checked = True
finally:
conn.close()
return
conn = get_db()
try:
with conn.cursor() as cur:
@@ -299,6 +338,10 @@ def _get_current_admin_permission_sets():
user_id = _get_current_admin_id()
if not user_id:
return set(), set()
cache_key = f'_admin_permission_sets_{user_id}'
cached = getattr(g, cache_key, None)
if cached is not None:
return cached
conn = get_db()
try:
with conn.cursor() as cur:
@@ -314,7 +357,9 @@ def _get_current_admin_permission_sets():
conn.close()
key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()}
route_set = {(row.get('route_path') or '').strip() for row in rows if (row.get('route_path') or '').strip()}
return key_set, route_set
cached = (key_set, route_set)
setattr(g, cache_key, cached)
return cached
def _ensure_admin_menu_access(*menu_names):
@@ -830,14 +875,6 @@ def list_columns():
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
params = {'menuType': menu_type} if menu_type else None
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/permission-menus',
params=params,
)
if error_response is None:
_sync_local_columns_from_backend([_format_permission_item(item) for item in (result.get('data') or [])])
local_rows = _load_local_columns(menu_type or None)
items = [
{
@@ -1484,32 +1521,7 @@ def _load_expanded_shop_manage_groups(role, current_row):
if error_response is not None:
return None, error_response, status
direct_groups = result.get('data') or []
if role == 'super_admin':
return direct_groups, None, 200
direct_ids = {item.get('id') for item in direct_groups if item.get('id')}
leader_ids = {item.get('leaderUserId') for item in direct_groups if item.get('leaderUserId')}
if not leader_ids:
return direct_groups, None, 200
all_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params={'superAdmin': 'true'},
)
if error_response is not None:
return None, error_response, status
merged = []
seen_ids = set()
for item in all_result.get('data') or []:
item_id = item.get('id')
if item_id in direct_ids or item.get('leaderUserId') in leader_ids:
if item_id not in seen_ids:
merged.append(item)
seen_ids.add(item_id)
return merged, None, 200
return result.get('data') or [], None, 200
@admin_api.route('/shop-manages')
@@ -1523,59 +1535,6 @@ def list_shop_manages():
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
if role != 'super_admin':
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
if error_response is not None:
return error_response, status
accessible_group_ids = [item.get('id') for item in (groups or []) if item.get('id')]
selected_group_id = None
if group_id_raw:
try:
selected_group_id = int(group_id_raw)
except (TypeError, ValueError):
selected_group_id = None
if selected_group_id and selected_group_id not in accessible_group_ids:
return jsonify({'success': True, 'items': [], 'total': 0, 'page': page, 'page_size': page_size})
query_group_ids = [selected_group_id] if selected_group_id else accessible_group_ids
all_items = []
for group_id in query_group_ids:
group_page = 1
while True:
group_params = {
'page': group_page,
'pageSize': 100,
'groupId': group_id,
'superAdmin': 'true',
}
if current_row and current_row.get('id'):
group_params['operatorId'] = current_row.get('id')
if shop_name:
group_params['shopName'] = shop_name
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages',
params=group_params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
rows = payload.get('items') or []
all_items.extend(rows)
total = payload.get('total') or 0
if group_page * 100 >= total or not rows:
break
group_page += 1
all_items.sort(key=lambda item: item.get('id') or 0, reverse=True)
total = len(all_items)
offset = (page - 1) * page_size
return jsonify({
'success': True,
'items': [_format_shop_manage_item(item) for item in all_items[offset:offset + page_size]],
'total': total,
'page': page,
'page_size': page_size,
})
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')

View File

@@ -23,9 +23,9 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -3,7 +3,7 @@
"""
from functools import wraps
from flask import request, redirect, url_for, session, jsonify
from flask import request, redirect, url_for, session, jsonify, g
from utils.db import get_db
@@ -13,15 +13,22 @@ def is_session_user_valid():
uid = session.get('user_id')
if not uid:
return False
cached_user = getattr(g, '_current_user_row', None)
if cached_user and cached_user.get('id') == uid:
return True
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(uid,)
)
row = cur.fetchone()
conn.close()
if not row:
session.clear()
return False
g._current_user_row = row
return True
except Exception:
session.clear()
@@ -34,16 +41,19 @@ def get_current_admin_role():
if not uid:
return None, None
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(uid,)
)
row = cur.fetchone()
conn.close()
row = getattr(g, '_current_user_row', None)
if not row or row.get('id') != uid:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(uid,)
)
row = cur.fetchone()
conn.close()
if not row:
return None, None
g._current_user_row = row
role = (row.get('role') or '').strip().lower()
if not role:
role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else (

View File

@@ -6,12 +6,20 @@ import pymysql
from werkzeug.security import generate_password_hash
try:
from config import mysql_host, mysql_user, mysql_password, mysql_database
from config import mysql_host as config_mysql_host
from config import mysql_user as config_mysql_user
from config import mysql_password as config_mysql_password
from config import mysql_database as config_mysql_database
except ImportError:
mysql_host = os.environ.get('MYSQL_HOST', 'localhost')
mysql_user = os.environ.get('MYSQL_USER', 'root')
mysql_password = os.environ.get('MYSQL_PASSWORD', '')
mysql_database = os.environ.get('MYSQL_DATABASE', 'maixiang_ai')
config_mysql_host = 'localhost'
config_mysql_user = 'root'
config_mysql_password = ''
config_mysql_database = 'maixiang_ai'
mysql_host = os.environ.get('MYSQL_HOST', config_mysql_host)
mysql_user = os.environ.get('MYSQL_USER', config_mysql_user)
mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password)
mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database)
def get_db():

File diff suppressed because it is too large Load Diff

View File

@@ -336,6 +336,178 @@
font-size: 16px;
}
#shopManageGroupModal .shop-group-modal {
width: min(1180px, calc(100vw - 48px));
max-width: none;
max-height: calc(100vh - 48px);
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.shop-group-modal-header {
padding: 20px 24px 14px;
border-bottom: 1px solid #edf0f5;
flex: 0 0 auto;
}
.shop-group-modal-header h3 {
margin: 0;
}
.shop-group-editor {
padding: 18px 24px 14px;
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(240px, 320px) minmax(360px, 1fr);
gap: 16px;
align-items: start;
flex: 0 0 auto;
}
.shop-group-editor .form-group {
margin-bottom: 0;
}
.shop-group-member-select {
min-height: 128px;
max-height: 180px;
}
.shop-group-help {
color: #777;
font-size: 12px;
line-height: 1.5;
margin-top: 6px;
}
.shop-group-action-bar {
padding: 0 24px 12px;
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
flex: 0 0 auto;
}
#msgShopManageGroup {
margin: 0;
padding: 0 24px 10px;
min-height: 18px;
flex: 0 0 auto;
}
.shop-group-table-wrap {
margin: 0 24px;
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: auto;
min-height: 220px;
flex: 1 1 auto;
}
.shop-group-table {
min-width: 1040px;
table-layout: fixed;
}
.shop-group-table th {
position: sticky;
top: 0;
z-index: 1;
}
.shop-group-table th,
.shop-group-table td {
vertical-align: top;
}
.shop-group-table .col-index {
width: 58px;
}
.shop-group-table .col-name {
width: 130px;
}
.shop-group-table .col-leader {
width: 110px;
}
.shop-group-table .col-count {
width: 86px;
}
.shop-group-table .col-members {
width: auto;
}
.shop-group-table .col-time {
width: 120px;
}
.shop-group-table .col-action {
width: 128px;
}
.shop-group-time-cell,
.shop-group-action-cell {
white-space: nowrap;
}
.shop-group-name-cell,
.shop-group-leader-cell {
word-break: break-word;
}
.shop-group-members {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 96px;
overflow-y: auto;
padding-right: 4px;
}
.shop-group-member-chip {
display: inline-flex;
align-items: center;
max-width: 150px;
padding: 3px 8px;
border-radius: 999px;
background: #eef1ff;
color: #4f63d8;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shop-group-modal-footer {
padding: 14px 24px 18px;
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid #edf0f5;
flex: 0 0 auto;
}
@media (max-width: 900px) {
#shopManageGroupModal .shop-group-modal {
width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
}
.shop-group-editor {
grid-template-columns: 1fr;
}
.shop-group-table-wrap {
margin: 0 16px;
}
}
.column-permission-cards {
display: flex;
flex-wrap: wrap;
@@ -347,6 +519,97 @@
overflow: hidden;
}
.multi-select-native {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px !important;
height: 1px !important;
min-height: 0 !important;
padding: 0 !important;
border: 0 !important;
}
.multi-select-dropdown {
position: relative;
width: 100%;
}
.multi-select-trigger {
width: 100%;
min-height: 42px;
padding: 10px 34px 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
text-align: left;
cursor: pointer;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-trigger::after {
content: "";
position: absolute;
right: 12px;
top: 50%;
width: 7px;
height: 7px;
border-right: 1.5px solid #666;
border-bottom: 1.5px solid #666;
transform: translateY(-65%) rotate(45deg);
}
.multi-select-dropdown.open .multi-select-trigger {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12);
}
.multi-select-panel {
display: none;
position: absolute;
left: 0;
right: 0;
top: calc(100% + 6px);
z-index: 30;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14);
padding: 6px;
max-height: 220px;
overflow-y: auto;
}
.multi-select-dropdown.open .multi-select-panel {
display: block;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
}
.multi-select-option:hover {
background: #f4f6ff;
}
.multi-select-option input {
width: auto;
margin: 0;
}
.column-permission-card {
display: inline-flex;
align-items: center;
@@ -762,7 +1025,7 @@
<label>&nbsp;</label>
<button class="btn btn-secondary" id="btnChooseSkipPriceAsinShop" type="button">选择店铺</button>
</div>
<select id="skipPriceAsinCountries" multiple size="5">
<select id="skipPriceAsinCountries" class="js-dropdown-multi" multiple>
<option value="DE">德国</option>
<option value="UK">英国</option>
<option value="FR">法国</option>
@@ -840,7 +1103,7 @@
</div>
<div class="form-group" style="min-width:220px;">
<label>国家</label>
<select id="queryAsinCountries" multiple size="5" style="min-height:98px;">
<select id="queryAsinCountries" class="js-dropdown-multi" multiple>
<option value="DE">德国</option>
<option value="UK">英国</option>
<option value="FR">法国</option>
@@ -1063,48 +1326,62 @@
<!-- 分组管理弹窗 -->
<div class="modal-mask" id="shopManageGroupModal">
<div class="modal" style="max-width:760px;">
<h3>分组管理</h3>
<div class="form-row">
<div class="modal shop-group-modal">
<div class="shop-group-modal-header">
<h3>分组管理</h3>
</div>
<div class="shop-group-editor">
<input type="hidden" id="shopManageGroupEditId">
<input type="hidden" id="shopManageGroupLeaderUserId">
<div class="form-group" style="min-width:220px;">
<div class="form-group">
<label>组长</label>
<input type="text" id="shopManageGroupLeaderName" placeholder="当前登录用户" readonly
style="background:#f5f5f5;">
</div>
<div class="form-group" style="min-width:260px;">
<div class="form-group">
<label>分组名称</label>
<input type="text" id="shopManageGroupInput" placeholder="请输入分组名称">
</div>
<div class="form-group" style="min-width:280px;flex:1;">
<div class="form-group">
<label>组员</label>
<select id="shopManageGroupMemberSelect" multiple size="6" style="min-height:140px;">
<select id="shopManageGroupMemberSelect" class="shop-group-member-select" multiple size="6">
</select>
<div id="shopManageGroupMemberHelp" style="color:#888;font-size:12px;margin-top:6px;">可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。</div>
<div id="shopManageGroupMemberHelp" class="shop-group-help">可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。</div>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px;">
<div class="shop-group-action-bar">
<button class="btn" id="btnSaveShopManageGroup" type="button">保存分组</button>
<button class="btn btn-secondary" id="btnCancelShopManageGroupEdit" type="button">取消编辑</button>
</div>
<p class="msg" id="msgShopManageGroup"></p>
<table>
<thead>
<tr>
<th>序号</th>
<th>分组名称</th>
<th>组长</th>
<th>组员数量</th>
<th>组员</th>
<th>创建时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="shopManageGroupListBody"></tbody>
</table>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">
<div class="shop-group-table-wrap">
<table class="shop-group-table">
<colgroup>
<col class="col-index">
<col class="col-name">
<col class="col-leader">
<col class="col-count">
<col class="col-members">
<col class="col-time">
<col class="col-time">
<col class="col-action">
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>分组名称</th>
<th>组长</th>
<th>组员数量</th>
<th>组员</th>
<th>创建时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="shopManageGroupListBody"></tbody>
</table>
</div>
<div class="shop-group-modal-footer">
<button class="btn btn-secondary" id="btnCloseShopManageGroupModal" type="button">关闭</button>
</div>
</div>
@@ -1865,6 +2142,111 @@
});
}
var shopManageAllUsers = [];
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
var dropdownMultiSelects = {};
function initDropdownMultiSelect(selectId, placeholder) {
var select = document.getElementById(selectId);
if (!select || dropdownMultiSelects[selectId]) return;
select.classList.add('multi-select-native');
var wrapper = document.createElement('div');
wrapper.className = 'multi-select-dropdown';
wrapper.setAttribute('data-multi-select-id', selectId);
var trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'multi-select-trigger';
trigger.textContent = placeholder || '请选择';
var panel = document.createElement('div');
panel.className = 'multi-select-panel';
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(panel);
wrapper.appendChild(select);
function selectedOptions() {
return Array.from(select.options).filter(function (option) { return option.selected; });
}
function updateSummary() {
var selected = selectedOptions();
if (!selected.length) {
trigger.textContent = placeholder || '请选择';
trigger.title = '';
return;
}
var labels = selected.map(function (option) { return option.textContent || option.value; });
trigger.textContent = labels.join('、');
trigger.title = labels.join('、');
}
function renderOptions() {
panel.innerHTML = Array.from(select.options).map(function (option, index) {
var optionId = selectId + '_multi_' + index;
return '<label class="multi-select-option" for="' + escapeHtml(optionId) + '">' +
'<input type="checkbox" id="' + escapeHtml(optionId) + '" data-multi-option-index="' + index + '"' + (option.selected ? ' checked' : '') + '>' +
'<span>' + escapeHtml(option.textContent || option.value) + '</span>' +
'</label>';
}).join('');
panel.querySelectorAll('[data-multi-option-index]').forEach(function (checkbox) {
checkbox.onchange = function () {
var option = select.options[Number(checkbox.getAttribute('data-multi-option-index'))];
if (!option) return;
option.selected = checkbox.checked;
updateSummary();
select.dispatchEvent(new Event('change', { bubbles: true }));
};
});
}
trigger.onclick = function (event) {
event.stopPropagation();
document.querySelectorAll('.multi-select-dropdown.open').forEach(function (item) {
if (item !== wrapper) item.classList.remove('open');
});
wrapper.classList.toggle('open');
};
panel.onclick = function (event) {
event.stopPropagation();
};
select.addEventListener('change', function () {
panel.querySelectorAll('[data-multi-option-index]').forEach(function (checkbox) {
var option = select.options[Number(checkbox.getAttribute('data-multi-option-index'))];
checkbox.checked = !!(option && option.selected);
});
updateSummary();
});
renderOptions();
updateSummary();
dropdownMultiSelects[selectId] = {
refresh: function () {
renderOptions();
updateSummary();
}
};
}
function refreshDropdownMultiSelect(selectId) {
if (dropdownMultiSelects[selectId]) {
dropdownMultiSelects[selectId].refresh();
}
}
document.addEventListener('click', function () {
document.querySelectorAll('.multi-select-dropdown.open').forEach(function (item) {
item.classList.remove('open');
});
});
function getEligibleShopManageGroupUsers(leaderUserId) {
var canViewAllMembers = currentUserRole === 'super_admin';
return shopManageAllUsers.filter(function (u) {
@@ -1884,7 +2266,7 @@
});
var eligibleUsers = getEligibleShopManageGroupUsers(leaderUserId);
var options = eligibleUsers.map(function (u) {
return '<option value="' + u.id + '"' + (selectedMap[String(u.id)] ? ' selected' : '') + '>' + (u.username || '') + '</option>';
return '<option value="' + escapeHtml(u.id) + '"' + (selectedMap[String(u.id)] ? ' selected' : '') + '>' + escapeHtml(u.username || '') + '</option>';
});
if (options.length) {
sel.innerHTML = options.join('');
@@ -2405,6 +2787,7 @@
// ========== 店铺管理 ==========
var shopManagePage = 1, shopManagePageSize = 15;
var shopManageGroups = [];
var shopManageGroupsLoadedAt = 0;
var currentShopManageGroupGrantRoutes = [];
function buildShopManageQuery(page) {
@@ -2521,17 +2904,23 @@
if (chooseQueryShopSel && selectedChooseQueryShopId) chooseQueryShopSel.value = selectedChooseQueryShopId;
}
function loadShopManageGroups(selectedCreateId, selectedEditId) {
function loadShopManageGroups(selectedCreateId, selectedEditId, force) {
if (!force && shopManageGroups.length && Date.now() - shopManageGroupsLoadedAt < 30000) {
refreshShopGroupSelects(selectedCreateId, selectedEditId);
return Promise.resolve(shopManageGroups);
}
return fetch('/api/admin/shop-manage-groups')
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '加载分组失败');
shopManageGroups = res.items || [];
shopManageGroupsLoadedAt = Date.now();
refreshShopGroupSelects(selectedCreateId, selectedEditId);
return shopManageGroups;
})
.catch(function () {
shopManageGroups = [];
shopManageGroupsLoadedAt = 0;
refreshShopGroupSelects();
return [];
});
@@ -2576,14 +2965,28 @@
return;
}
tbody.innerHTML = shopManageGroups.map(function (item, index) {
var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames.join('、') : '';
var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames : [];
var memberHtml = memberNames.length
? '<div class="shop-group-members" title="' + escapeHtml(memberNames.join('、')) + '">' +
memberNames.map(function (name) {
return '<span class="shop-group-member-chip">' + escapeHtml(name || '') + '</span>';
}).join('') + '</div>'
: '<span style="color:#999;">-</span>';
var canEdit = currentUserRole === 'super_admin' || String(item.leader_user_id || '') === String(currentUserId || '');
var actionHtml = canEdit
? ('<button class="btn btn-sm" data-shop-group-edit="' + item.id + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-group-delete="' + item.id + '" data-shop-group-name="' + (item.group_name || '').replace(/"/g, '&quot;') + '">删除</button>')
? ('<button class="btn btn-sm" data-shop-group-edit="' + escapeHtml(item.id) + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-group-delete="' + escapeHtml(item.id) + '" data-shop-group-name="' + escapeHtml(item.group_name || '') + '">删除</button>')
: '<span style="color:#999;">-</span>';
return '<tr><td>' + (index + 1) + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.leader_username || '') + '</td><td>' + (item.member_count || 0) + '</td><td>' + (memberNames || '-') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
actionHtml + '</td></tr>';
return '<tr>' +
'<td>' + (index + 1) + '</td>' +
'<td class="shop-group-name-cell">' + escapeHtml(item.group_name || '') + '</td>' +
'<td class="shop-group-leader-cell">' + escapeHtml(item.leader_username || '') + '</td>' +
'<td>' + escapeHtml(item.member_count || 0) + '</td>' +
'<td>' + memberHtml + '</td>' +
'<td class="shop-group-time-cell">' + escapeHtml(item.created_at || '') + '</td>' +
'<td class="shop-group-time-cell">' + escapeHtml(item.updated_at || '') + '</td>' +
'<td class="shop-group-action-cell">' + actionHtml + '</td>' +
'</tr>';
}).join('');
document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) {
@@ -2617,7 +3020,7 @@
alert(res.error || '删除失败');
return;
}
loadShopManageGroups().then(function () {
loadShopManageGroups(null, null, true).then(function () {
renderShopManageGroupRows();
loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
@@ -2689,7 +3092,7 @@
resetShopManageGroupForm();
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
loadShopManageGroups().then(function () {
loadShopManageGroups(null, null, true).then(function () {
renderShopManageGroupRows();
loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
@@ -3174,6 +3577,7 @@
Array.from(document.getElementById('skipPriceAsinCountries').options).forEach(function (option) {
option.selected = false;
});
refreshDropdownMultiSelect('skipPriceAsinCountries');
renderSkipPriceAsinInputs();
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
@@ -3185,6 +3589,7 @@
});
};
setupSkipPriceAsinShopPicker();
initDropdownMultiSelect('skipPriceAsinCountries', '请选择国家');
renderSkipPriceAsinInputs();
// ========== 查询 ASIN ==========
@@ -3677,6 +4082,7 @@
Array.from(document.getElementById('queryAsinCountries').options).forEach(function (option) {
option.selected = false;
});
refreshDropdownMultiSelect('queryAsinCountries');
renderQueryAsinInputs();
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
@@ -3687,6 +4093,7 @@
msgEl.className = 'msg err';
});
};
initDropdownMultiSelect('queryAsinCountries', '请选择国家');
document.getElementById('btnImportQueryAsin').onclick = function () {
uploadQueryAsinImport(false);
};

View File

@@ -153,6 +153,7 @@ import {
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -171,6 +172,8 @@ const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('appearance-patent')
const dashboard = ref<AppearancePatentDashboardVo>({
pendingTaskCount: 0,
@@ -389,33 +392,36 @@ function removePollingTask(taskId: number) {
}
function ensurePolling() {
if (disposed) return
if (pollTimer.value != null) return
scheduleNextPoll(true)
}
function stopPolling() {
if (pollTimer.value != null) {
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value != null) {
if (!immediate) return
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const run = async () => {
pollTimer.value = null
if (disposed) return
if (!pollingTaskIds.value.length) return
await refreshTaskProgress()
if (pollingTaskIds.value.length) {
pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
if (!disposed && pollingTaskIds.value.length) {
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
}
}
if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
}
async function refreshTaskProgress() {
@@ -547,7 +553,11 @@ onMounted(async () => {
])
})
onUnmounted(() => stopPolling())
onUnmounted(() => {
disposed = true
stopPolling()
timers.clearScope()
})
</script>
<style scoped>

View File

@@ -226,6 +226,7 @@ import {
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean
@@ -263,6 +264,8 @@ const activeItemKey = ref('')
const autoAdvancing = ref(false)
const autoRetryTimer = ref<number | null>(null)
const waitingForBackendRecovery = ref(false)
let disposed = false
const timers = createCategorizedTimers('delete-brand')
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => {
@@ -892,14 +895,16 @@ function getPollIntervalMs() {
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) {
if (!immediate) return
clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const executePoll = async () => {
pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) {
scheduleNextPoll()
return
@@ -933,7 +938,7 @@ function scheduleNextPoll(immediate = false) {
syncResultState()
}
if (getPollingTaskIds().length > 0) {
if (!disposed && getPollingTaskIds().length > 0) {
scheduleNextPoll()
}
}
@@ -941,7 +946,7 @@ function scheduleNextPoll(immediate = false) {
if (immediate) {
executePoll()
} else {
pollTimer.value = window.setTimeout(executePoll, getPollIntervalMs())
pollTimer.value = timers.setTimeout('task-poll', executePoll, getPollIntervalMs())
}
}
@@ -969,7 +974,7 @@ function getTaskStatusInfo(item: DeleteBrandResultItem) {
function stopPolling() {
if (pollTimer.value) {
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
clearAutoRetryTimer()
@@ -1178,16 +1183,17 @@ function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
function clearAutoRetryTimer() {
if (autoRetryTimer.value) {
window.clearTimeout(autoRetryTimer.value)
timers.clearTimer('auto-retry', autoRetryTimer.value)
autoRetryTimer.value = null
}
}
function scheduleAutoAdvanceRetry(delayMs = 3000) {
if (disposed) return
clearAutoRetryTimer()
autoRetryTimer.value = window.setTimeout(() => {
autoRetryTimer.value = timers.setTimeout('auto-retry', () => {
autoRetryTimer.value = null
maybeAutoAdvance().catch(() => undefined)
if (!disposed) maybeAutoAdvance().catch(() => undefined)
}, delayMs)
}
@@ -1428,7 +1434,9 @@ onMounted(() => {
})
onUnmounted(() => {
disposed = true
stopPolling()
timers.clearScope()
})
</script>

View File

@@ -334,6 +334,7 @@ import {
} from "@/shared/api/java-modules";
import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
const MAX_TRANSIENT_ERRORS = 30;
@@ -360,6 +361,7 @@ const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("patrol-delete");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched),
@@ -399,8 +401,15 @@ function historyItemKey(item: PatrolDeleteHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
}
let disposed = false;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
if (disposed) return Promise.resolve();
return timers.sleep("queue-wait", ms);
}
function clearSleepTimers() {
timers.clearCategory("queue-wait");
}
function isTransientBackendError(error: unknown) {
@@ -425,6 +434,7 @@ async function withTransientRetry<T>(
) {
let attempt = 0;
while (true) {
if (disposed) throw new Error("component disposed");
try {
return await action();
} catch (error) {
@@ -716,21 +726,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
function startHistoryPolling() {
stopHistoryPolling();
if (!hasQueueWork.value) return;
if (disposed || !hasQueueWork.value) return;
const run = async () => {
historyPollTimer = null;
if (!hasQueueWork.value) return;
if (disposed || !hasQueueWork.value) return;
await refreshActiveTaskProgress();
if (hasQueueWork.value) {
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
if (!disposed && hasQueueWork.value) {
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
};
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
function stopHistoryPolling() {
if (historyPollTimer) {
window.clearTimeout(historyPollTimer);
timers.clearTimer("history-poll", historyPollTimer);
historyPollTimer = null;
}
}
@@ -865,6 +875,7 @@ function findHistoryItemByTaskId(taskId: number) {
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0;
while (true) {
if (disposed) return "STOPPED";
try {
await refreshActiveTaskProgress([taskId]);
if (transientErrorCount > 0) {
@@ -893,7 +904,7 @@ async function waitForTaskTerminal(taskId: number) {
}
async function processQueue() {
if (queueWorkerRunning.value) return;
if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true;
pushing.value = true;
startHistoryPolling();
@@ -904,7 +915,7 @@ async function processQueue() {
throw new Error("当前客户端未提供 enqueue_json");
}
while (activeTaskId.value || pendingQueue.value.length) {
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value =
@@ -975,6 +986,7 @@ async function processQueue() {
await refreshTaskViews();
ElMessage.success("巡店删除队列已按顺序执行完成");
} catch (error) {
if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败";
queuePushResult.value = message;
ElMessage.error(message);
@@ -1061,7 +1073,10 @@ onMounted(async () => {
});
onUnmounted(() => {
disposed = true;
stopHistoryPolling();
clearSleepTimers();
timers.clearScope();
});
</script>

View File

@@ -265,6 +265,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
@@ -369,6 +370,17 @@ const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('price-track')
function sleep(ms: number) {
if (disposed) return Promise.resolve()
return timers.sleep('queue-wait', ms)
}
function clearSleepTimers() {
timers.clearCategory('queue-wait')
}
function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
@@ -649,8 +661,8 @@ function onCountryDrop(toIndex: number) {
}
function scheduleSaveCountryPref() {
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
countryPrefSaveTimer = setTimeout(() => {
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
countryPrefSaveTimer = null
void persistCountryPref()
}, 450)
@@ -1043,6 +1055,7 @@ async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
if (disposed) return 'STOPPED'
try {
const batch = await getPriceTrackTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
@@ -1086,10 +1099,10 @@ async function waitForTaskTerminal(taskId: number) {
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
}
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
continue
}
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
}
}
@@ -1103,6 +1116,7 @@ function nextMatchedQueueItem() {
}
async function processMatchedQueue() {
if (disposed) return
if (queueWorkerRunning.value) {
return
}
@@ -1131,7 +1145,7 @@ async function processMatchedQueue() {
let failedCount = 0
try {
let index = 0
while (autoQueueEnabled.value) {
while (!disposed && autoQueueEnabled.value) {
const row = nextMatchedQueueItem()
if (!row) {
break
@@ -1141,6 +1155,7 @@ async function processMatchedQueue() {
let attempt = 0
const taskVo = await (async () => {
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await createPriceTrackTask({
userId: 0,
@@ -1156,7 +1171,7 @@ async function processMatchedQueue() {
attempt += 1
if (!transient || attempt >= 10) throw error
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10...'
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
}
}
})()
@@ -1197,6 +1212,7 @@ async function processMatchedQueue() {
successCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
} catch (error) {
if (disposed) break
failedCount += 1
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
@@ -1208,6 +1224,7 @@ async function processMatchedQueue() {
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
}
} catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
@@ -1282,6 +1299,7 @@ async function stopLoopExecution() {
}
async function runLoopExecution(loopId?: number) {
if (disposed) return
if (loopDispatching.value) return
if (loopId && activeLoopRunId.value !== loopId) {
activeLoopRunId.value = loopId
@@ -1294,7 +1312,7 @@ async function runLoopExecution(loopId?: number) {
}
loopDispatching.value = true
try {
while (activeLoopRunId.value) {
while (!disposed && activeLoopRunId.value) {
const loop = await syncActiveLoopRun()
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
@@ -1306,6 +1324,7 @@ async function runLoopExecution(loopId?: number) {
let completeAttempt = 0
const updatedLoop = await (async () => {
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await completePriceTrackLoopChild(loop.id, activeTaskId)
} catch (error) {
@@ -1314,7 +1333,7 @@ async function runLoopExecution(loopId?: number) {
completeAttempt += 1
if (!transient || completeAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
}
}
})()
@@ -1332,6 +1351,7 @@ async function runLoopExecution(loopId?: number) {
let dispatchAttempt = 0
const dispatch = await (async () => {
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await dispatchNextPriceTrackLoopRun(loop.id)
} catch (error) {
@@ -1340,7 +1360,7 @@ async function runLoopExecution(loopId?: number) {
dispatchAttempt += 1
if (!transient || dispatchAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
}
}
})()
@@ -1355,6 +1375,7 @@ async function runLoopExecution(loopId?: number) {
let createAttempt = 0
const taskVo = await (async () => {
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await createPriceTrackTask(childTaskRequest)
} catch (error) {
@@ -1363,7 +1384,7 @@ async function runLoopExecution(loopId?: number) {
createAttempt += 1
if (!transient || createAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
await sleep(getPollIntervalMs())
}
}
})()
@@ -1483,25 +1504,27 @@ async function refreshTaskBatch() {
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) {
if (!immediate) return
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const run = async () => {
pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) { scheduleNextPoll(); return }
if (!pollingTaskIds.value.length) return
pollingInFlight.value = true
try { await refreshTaskBatch() } finally { pollingInFlight.value = false }
if (pollingTaskIds.value.length > 0) pollTimer.value = window.setTimeout(run, getPollIntervalMs())
if (!disposed && pollingTaskIds.value.length > 0) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
function stopPolling() {
if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null }
if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null }
}
function addPollingTask(taskId: number) {
@@ -1658,8 +1681,11 @@ onMounted(() => {
})
onUnmounted(() => {
disposed = true
stopPolling()
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
})
</script>

View File

@@ -221,6 +221,7 @@ import {
} from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const shopInput = ref('')
const candidates = ref<ProductRiskCandidateVo[]>([])
@@ -327,8 +328,8 @@ function onCountryDrop(toIndex: number) {
}
function scheduleSaveCountryPref() {
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
countryPrefSaveTimer = setTimeout(() => {
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
countryPrefSaveTimer = null
void persistCountryPref()
}, 450)
@@ -374,6 +375,8 @@ const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('product-risk')
function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
@@ -684,9 +687,12 @@ const TRANSIENT_BACKEND_ERROR_PATTERNS = [
] as const
function sleep(ms: number) {
return new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), ms)
})
if (disposed) return Promise.resolve()
return timers.sleep('queue-wait', ms)
}
function clearSleepTimers() {
timers.clearCategory('queue-wait')
}
function isTransientBackendError(error: unknown) {
@@ -697,6 +703,7 @@ function isTransientBackendError(error: unknown) {
async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
let attempt = 0
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await createProductRiskTask(items)
} catch (error) {
@@ -751,12 +758,13 @@ function getPollIntervalMs() {
function scheduleNextPoll(immediate = false) {
if (pollTimer.value) {
if (!immediate) return
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const run = async () => {
pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) {
scheduleNextPoll()
return
@@ -768,13 +776,14 @@ function scheduleNextPoll(immediate = false) {
} finally {
pollingInFlight.value = false
}
if (pollingTaskIds.value.length > 0) {
pollTimer.value = window.setTimeout(run, getPollIntervalMs())
if (!disposed && pollingTaskIds.value.length > 0) {
pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
}
if (disposed) return
if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
function ensurePolling(immediate = false) {
@@ -784,7 +793,7 @@ function ensurePolling(immediate = false) {
function stopPolling() {
if (pollTimer.value) {
window.clearTimeout(pollTimer.value)
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
}
@@ -793,6 +802,7 @@ async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
if (disposed) return 'STOPPED'
try {
const batch = await getProductRiskTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
@@ -1027,7 +1037,7 @@ function nextMatchedQueueItem() {
}
async function processMatchedQueue() {
if (queueWorkerRunning.value) {
if (disposed || queueWorkerRunning.value) {
return
}
if (!matchedItems.value.length) {
@@ -1051,7 +1061,7 @@ async function processMatchedQueue() {
let failedCount = 0
try {
let index = 0
while (autoQueueEnabled.value) {
while (!disposed && autoQueueEnabled.value) {
const item = nextMatchedQueueItem()
if (!item) {
break
@@ -1102,6 +1112,7 @@ async function processMatchedQueue() {
successCount += 1
queuePushResult.value = '任务 ' + taskId + ' 已完成'
} catch (error) {
if (disposed) break
failedCount += 1
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
@@ -1115,6 +1126,7 @@ async function processMatchedQueue() {
await loadHistory()
ensurePolling(true)
} catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
@@ -1141,8 +1153,11 @@ onMounted(() => {
})
onUnmounted(() => {
disposed = true
stopPolling()
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
})
</script>

View File

@@ -333,6 +333,7 @@ import {
} from "@/shared/api/java-modules";
import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
const MAX_TRANSIENT_ERRORS = 30;
@@ -358,6 +359,7 @@ const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("query-asin");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched && (item.queryAsins || []).some((row) => row.asins?.length)),
@@ -398,8 +400,15 @@ function historyItemKey(item: QueryAsinHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
}
let disposed = false;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
if (disposed) return Promise.resolve();
return timers.sleep("queue-wait", ms);
}
function clearSleepTimers() {
timers.clearCategory("queue-wait");
}
function isTransientBackendError(error: unknown) {
@@ -424,6 +433,7 @@ async function withTransientRetry<T>(
) {
let attempt = 0;
while (true) {
if (disposed) throw new Error("component disposed");
try {
return await action();
} catch (error) {
@@ -747,21 +757,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
function startHistoryPolling() {
stopHistoryPolling();
if (!hasQueueWork.value) return;
if (disposed || !hasQueueWork.value) return;
const run = async () => {
historyPollTimer = null;
if (!hasQueueWork.value) return;
if (disposed || !hasQueueWork.value) return;
await refreshActiveTaskProgress();
if (hasQueueWork.value) {
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
if (!disposed && hasQueueWork.value) {
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
};
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
function stopHistoryPolling() {
if (historyPollTimer) {
window.clearTimeout(historyPollTimer);
timers.clearTimer("history-poll", historyPollTimer);
historyPollTimer = null;
}
}
@@ -893,6 +903,7 @@ function findHistoryItemByTaskId(taskId: number) {
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0;
while (true) {
if (disposed) return "STOPPED";
try {
await refreshActiveTaskProgress([taskId]);
if (activeTaskId.value !== taskId) {
@@ -924,7 +935,7 @@ async function waitForTaskTerminal(taskId: number) {
}
async function processQueue() {
if (queueWorkerRunning.value) return;
if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true;
pushing.value = true;
startHistoryPolling();
@@ -935,7 +946,7 @@ async function processQueue() {
throw new Error("当前客户端未提供 enqueue_json");
}
while (activeTaskId.value || pendingQueue.value.length) {
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value =
@@ -1002,6 +1013,7 @@ async function processQueue() {
await refreshTaskViews();
ElMessage.success("查询ASIN队列已按顺序执行完成");
} catch (error) {
if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败";
queuePushResult.value = message;
ElMessage.error(message);
@@ -1092,7 +1104,10 @@ onMounted(async () => {
});
onUnmounted(() => {
disposed = true;
stopHistoryPolling();
clearSleepTimers();
timers.clearScope();
});
</script>

View File

@@ -111,6 +111,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
const shopInput = ref('')
@@ -144,6 +145,8 @@ const scheduledDispatchInFlight = ref(false)
let scheduleHeartbeatTimer: number | null = null
let lastScheduledErrorAt = 0
let lastScheduledErrorKey = ''
let disposed = false
const timers = createCategorizedTimers('shop-match')
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
@@ -194,7 +197,7 @@ function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(),
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message) }
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat() } }
function removePollingTask(taskId: number) {
clearDispatchTimer(taskId)
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
@@ -226,7 +229,7 @@ async function loadDashboard() { dashboard.value = await getShopMatchDashboard()
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
async function loadCountryPreference() { try { const data = await getShopMatchCountryPreference(); if (countryPrefUserTouched.value) return; const codes = (data.country_codes || []).filter((item) => COUNTRY_OPTIONS.some((option) => option.code === item)); if (codes.length) orderedCountryCodes.value = codes } catch {} }
async function refreshTaskViewsBestEffort() { await Promise.allSettled([loadHistory(), loadDashboard()]) }
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); countryPrefSaveTimer = timers.setTimeout('preference-save', () => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
async function saveCountryPreference() { if (!orderedCountryCodes.value.length) return; countryPrefSaving.value = true; try { await putShopMatchCountryPreference(orderedCountryCodes.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家配置失败') } finally { countryPrefSaving.value = false } }
function onSelectionChange(rows: ShopMatchCandidateVo[]) { selectedCandidates.value = rows }
async function confirmAdd() { const name = shopInput.value.trim(); if (!name) { ElMessage.warning('请输入店铺名'); return } adding.value = true; try { await addShopMatchCandidate(name); shopInput.value = ''; await loadCandidates(); await loadDashboard() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '添加失败') } finally { adding.value = false } }
@@ -320,25 +323,27 @@ async function pumpScheduledQueue() {
restoreScheduledDispatches()
}
}
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (disposed || !scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = timers.setTimeout('scheduled-dispatch', () => { dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat(); if (!disposed) void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer); startScheduleHeartbeat() }
function restoreScheduledDispatches() { if (disposed) return; const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } if (dispatchTimers.size) startScheduleHeartbeat(); else stopScheduleHeartbeat(); void pumpScheduledQueue() }
function startScheduleHeartbeat() { if (disposed || scheduleHeartbeatTimer || !dispatchTimers.size) return; scheduleHeartbeatTimer = timers.setInterval('schedule-heartbeat', () => { if (!disposed) void pumpScheduledQueue() }, 30000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; timers.clearTimer('schedule-heartbeat', scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
function sleep(ms: number) { return new Promise<void>((resolve) => { window.setTimeout(() => resolve(), ms) }) }
function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep('queue-wait', ms) }
function clearSleepTimers() { timers.clearCategory('queue-wait') }
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { if (disposed) throw new Error('component disposed'); try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return getTaskPollIntervalMs() }
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
async function waitForScheduledTaskStageExit(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
if (disposed) return 'STOPPED'
try {
const batch = await getShopMatchTaskProgressBatch([taskId])
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待当前轮次结束...`
@@ -429,9 +434,9 @@ function formatMatchStatus(status?: string) { const value = (status || '').trim(
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
async function processMatchedQueue() { if (queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (successCount > 0 || failedCount > 0) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
</script>
<style scoped>

View File

@@ -0,0 +1,113 @@
type TimerKind = 'timeout' | 'interval'
type TimerCancel = () => void
interface TimerEntry {
id: number
kind: TimerKind
category: string
cancel?: TimerCancel
}
const timerBuckets = new Map<string, Map<number, TimerEntry>>()
function bucketOf(category: string) {
let bucket = timerBuckets.get(category)
if (!bucket) {
bucket = new Map<number, TimerEntry>()
timerBuckets.set(category, bucket)
}
return bucket
}
function removeTimer(category: string, id: number) {
const bucket = timerBuckets.get(category)
if (!bucket) return
bucket.delete(id)
if (!bucket.size) timerBuckets.delete(category)
}
export function setCategorizedTimeout(category: string, handler: () => void, delayMs: number) {
const id = window.setTimeout(() => {
removeTimer(category, id)
handler()
}, delayMs)
bucketOf(category).set(id, { id, kind: 'timeout', category })
return id
}
export function setCategorizedInterval(category: string, handler: () => void, delayMs: number) {
const id = window.setInterval(handler, delayMs)
bucketOf(category).set(id, { id, kind: 'interval', category })
return id
}
export function clearCategorizedTimer(category: string, id: number | null | undefined) {
if (id == null) return
const bucket = timerBuckets.get(category)
const entry = bucket?.get(id)
if (entry?.kind === 'interval') window.clearInterval(id)
else window.clearTimeout(id)
entry?.cancel?.()
removeTimer(category, id)
}
export function clearCategorizedTimers(category: string) {
const bucket = timerBuckets.get(category)
if (!bucket) return
for (const entry of bucket.values()) {
if (entry.kind === 'interval') window.clearInterval(entry.id)
else window.clearTimeout(entry.id)
entry.cancel?.()
}
timerBuckets.delete(category)
}
export function sleepWithCategory(category: string, delayMs: number) {
return new Promise<void>((resolve) => {
const id = window.setTimeout(() => {
removeTimer(category, id)
resolve()
}, delayMs)
bucketOf(category).set(id, { id, kind: 'timeout', category, cancel: resolve })
})
}
export function clearTimerScope(scope: string) {
const prefix = `${scope}:`
for (const category of Array.from(timerBuckets.keys())) {
if (category === scope || category.startsWith(prefix)) {
clearCategorizedTimers(category)
}
}
}
export function createCategorizedTimers(scope: string) {
const key = (category: string) => `${scope}:${category}`
return {
setTimeout(category: string, handler: () => void, delayMs: number) {
return setCategorizedTimeout(key(category), handler, delayMs)
},
setInterval(category: string, handler: () => void, delayMs: number) {
return setCategorizedInterval(key(category), handler, delayMs)
},
clearTimer(category: string, id: number | null | undefined) {
clearCategorizedTimer(key(category), id)
},
clearCategory(category: string) {
clearCategorizedTimers(key(category))
},
clearScope() {
clearTimerScope(scope)
},
sleep(category: string, delayMs: number) {
return sleepWithCategory(key(category), delayMs)
},
}
}
export function getCategorizedTimerStats() {
return Array.from(timerBuckets.entries()).map(([category, bucket]) => ({
category,
count: bucket.size,
}))
}