改造后台权限和APP权限

This commit is contained in:
super
2026-04-19 15:06:09 +08:00
parent ef0e0df0ac
commit 4b6295dd44
35 changed files with 1487 additions and 85 deletions

View File

@@ -15,6 +15,12 @@ public class PermissionMenuSchemaInitializer {
private final JdbcTemplate jdbcTemplate;
private static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
);
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
@@ -44,6 +50,7 @@ public class PermissionMenuSchemaInitializer {
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
ensureDefaultAdminMenus();
ensureDefaultAppMenus();
executeQuietly("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
@@ -72,6 +79,23 @@ public class PermissionMenuSchemaInitializer {
}
}
private void ensureDefaultAppMenus() {
for (DefaultAppMenu menu : DEFAULT_APP_MENUS) {
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
SELECT '%s', '%s', 'app', '%s', %d
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = '%s'
)
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
executeQuietly("""
UPDATE columns
SET sort_order = %d, route_path = '%s', menu_type = 'app'
WHERE column_key = '%s'
""".formatted(menu.sortOrder(), menu.routePath(), menu.columnKey()));
}
}
private void executeQuietly(String sql) {
try {
jdbcTemplate.execute(sql);
@@ -82,4 +106,7 @@ public class PermissionMenuSchemaInitializer {
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
}
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
}
}

View File

@@ -4,6 +4,8 @@ import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
@@ -12,9 +14,12 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreference
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import io.swagger.v3.oas.annotations.Operation;
@@ -53,6 +58,7 @@ public class PriceTrackController {
private final PriceTrackService priceTrackService;
private final PriceTrackTaskService priceTrackTaskService;
private final PriceTrackLoopRunService priceTrackLoopRunService;
@GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
@@ -131,6 +137,45 @@ public class PriceTrackController {
return ApiResponse.success(priceTrackTaskService.createTask(request));
}
@PostMapping("/loop-runs")
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {
return ApiResponse.success(priceTrackLoopRunService.createLoopRun(request));
}
@GetMapping("/loop-runs/{loopRunId}")
@Operation(summary = "查询跟价循环任务", description = "返回循环任务当前状态与进度。")
public ApiResponse<PriceTrackLoopRunVo> getLoopRun(
@PathVariable Long loopRunId,
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackLoopRunService.getLoopRun(loopRunId, userId));
}
@PostMapping("/loop-runs/{loopRunId}/dispatch-next")
@Operation(summary = "获取循环任务下一条派发信息", description = "当前无活跃子任务时调用,返回下一条应该创建并入队的子任务请求。")
public ApiResponse<PriceTrackLoopRunDispatchVo> dispatchNext(
@PathVariable Long loopRunId,
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackLoopRunService.dispatchNext(loopRunId, userId));
}
@PostMapping("/loop-runs/{loopRunId}/child-finished")
@Operation(summary = "通知循环任务子任务已结束", description = "子任务进入终态后调用,用于推进到下一店或下一轮。")
public ApiResponse<PriceTrackLoopRunVo> childFinished(
@PathVariable Long loopRunId,
@RequestParam("user_id") Long userId,
@Valid @RequestBody PriceTrackLoopRunChildFinishedRequest request) {
return ApiResponse.success(priceTrackLoopRunService.childFinished(loopRunId, userId, request));
}
@PostMapping("/loop-runs/{loopRunId}/stop")
@Operation(summary = "停止循环任务", description = "请求停止循环;若无活跃子任务则立即停止,否则等待当前子任务完成后收尾。")
public ApiResponse<PriceTrackLoopRunVo> stopLoopRun(
@PathVariable Long loopRunId,
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackLoopRunService.requestStop(loopRunId, userId));
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行RUNNING、SUCCESS、FAILED 均可删除。")
public ApiResponse<Void> deleteTask(

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.pricetrack.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PriceTrackLoopRunMapper extends BaseMapper<PriceTrackLoopRunEntity> {
}

View File

@@ -33,4 +33,12 @@ public class PriceTrackCreateTaskRequest {
@NotEmpty(message = "国家列表不能为空")
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
private List<String> countryCodes;
@Schema(description = "optional loop run id", example = "1")
private Long loopRunId;
@Schema(description = "optional round index, 1-based", example = "2")
private Integer roundIndex;
@Schema(description = "optional shop index, 0-based", example = "0")
private Integer shopIndex;
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "循环任务子任务完成通知")
public class PriceTrackLoopRunChildFinishedRequest {
@NotNull(message = "childTaskId不能为空")
private Long childTaskId;
}

View File

@@ -0,0 +1,40 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建跟价循环任务请求")
public class PriceTrackLoopRunCreateRequest {
@NotNull(message = "user_id不能为空")
private Long userId;
private boolean statusMode;
private boolean asinMode;
@NotEmpty(message = "items不能为空")
@Valid
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
private List<String> asinFiles = new ArrayList<>();
@NotEmpty(message = "countryCodes不能为空")
private List<String> countryCodes = new ArrayList<>();
@NotBlank(message = "executionMode不能为空")
@Schema(description = "FINITE or INFINITE", example = "FINITE")
private String executionMode;
@Schema(description = "固定轮次时必填", example = "3")
private Integer targetRounds;
}

View File

@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.pricetrack.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_price_track_loop_run")
public class PriceTrackLoopRunEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String status;
private String executionMode;
private Integer targetRounds;
private Integer currentRound;
private Integer currentShopIndex;
private Boolean statusMode;
private Boolean asinMode;
private String asinFilesJson;
private String countryCodesJson;
private String shopsJson;
private Long activeTaskId;
private Boolean stopRequested;
private String errorMessage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime finishedAt;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PriceTrackCreateTaskRequestVo {
private Long userId;
private boolean statusMode;
private boolean asinMode;
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
private List<String> asinFiles = new ArrayList<>();
private List<String> countryCodes = new ArrayList<>();
private Long loopRunId;
private Integer roundIndex;
private Integer shopIndex;
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "循环任务下一条派发信息")
public class PriceTrackLoopRunDispatchVo {
private PriceTrackLoopRunVo loopRun;
private PriceTrackCreateTaskRequestVo childTaskRequest;
}

View File

@@ -0,0 +1,28 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "跟价循环任务")
public class PriceTrackLoopRunVo {
private Long id;
private String status;
private String executionMode;
private Integer targetRounds;
private Integer currentRound;
private Integer currentShopIndex;
private Integer totalShopCount;
private Long activeTaskId;
private String activeTaskStatus;
private Boolean stopRequested;
private String errorMessage;
private Boolean statusMode;
private Boolean asinMode;
private List<String> asinFiles = new ArrayList<>();
private List<String> countryCodes = new ArrayList<>();
}

View File

@@ -49,4 +49,10 @@ public class PriceTrackResultItemVo {
@Schema(description = "预签名下载 URL列表可能带回也可通过下载接口获取")
private String downloadUrl;
@Schema(description = "所属循环任务 ID", example = "1")
private Long loopRunId;
@Schema(description = "所属轮次1-based", example = "2")
private Integer roundIndex;
}

View File

@@ -26,4 +26,10 @@ public class PriceTrackTaskItemVo {
@Schema(description = "完成时间;未结束时为空")
private String finishedAt;
@Schema(description = "所属循环任务 ID", example = "1")
private Long loopRunId;
@Schema(description = "所属轮次1-based", example = "2")
private Integer roundIndex;
}

View File

@@ -0,0 +1,479 @@
package com.nanri.aiimage.modules.pricetrack.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackLoopRunMapper;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskRequestVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class PriceTrackLoopRunService {
private static final String MODULE_TYPE = "PRICE_TRACK";
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_STOPPED = "STOPPED";
private static final String EXECUTION_MODE_FINITE = "FINITE";
private static final String EXECUTION_MODE_INFINITE = "INFINITE";
private final PriceTrackLoopRunMapper loopRunMapper;
private final FileTaskMapper fileTaskMapper;
private final ObjectMapper objectMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
@Transactional
public PriceTrackLoopRunVo createLoopRun(PriceTrackLoopRunCreateRequest request) {
validateCreateRequest(request);
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = dedupeItems(request.getItems());
if (items.isEmpty()) {
throw new BusinessException("items不能为空");
}
PriceTrackLoopRunEntity entity = new PriceTrackLoopRunEntity();
entity.setUserId(request.getUserId());
entity.setStatus(STATUS_RUNNING);
entity.setExecutionMode(normalizeExecutionMode(request.getExecutionMode()));
entity.setTargetRounds("FINITE".equals(normalizeExecutionMode(request.getExecutionMode())) ? request.getTargetRounds() : null);
entity.setCurrentRound(1);
entity.setCurrentShopIndex(0);
entity.setStatusMode(request.isStatusMode());
entity.setAsinMode(request.isAsinMode());
entity.setActiveTaskId(null);
entity.setStopRequested(false);
entity.setErrorMessage(null);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
try {
entity.setAsinFilesJson(objectMapper.writeValueAsString(request.getAsinFiles() == null ? List.of() : request.getAsinFiles()));
entity.setCountryCodesJson(objectMapper.writeValueAsString(request.getCountryCodes()));
entity.setShopsJson(objectMapper.writeValueAsString(items));
} catch (Exception ex) {
throw new BusinessException("循环任务序列化失败");
}
loopRunMapper.insert(entity);
return toVo(entity);
}
public PriceTrackLoopRunVo getLoopRun(Long loopRunId, Long userId) {
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
reconcileWithTerminalChild(entity);
return toVo(entity);
}
@Transactional
public PriceTrackLoopRunDispatchVo dispatchNext(Long loopRunId, Long userId) {
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
reconcileWithTerminalChild(entity);
if (isTerminal(entity.getStatus())) {
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
vo.setLoopRun(toVo(entity));
vo.setChildTaskRequest(null);
return vo;
}
if (Boolean.TRUE.equals(entity.getStopRequested()) && entity.getActiveTaskId() == null) {
markStopped(entity, null);
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
vo.setLoopRun(toVo(entity));
vo.setChildTaskRequest(null);
return vo;
}
if (entity.getActiveTaskId() != null) {
throw new BusinessException("当前仍有执行中的子任务");
}
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
if (items.isEmpty()) {
throw new BusinessException("循环任务缺少店铺配置");
}
int shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
if (shopIndex >= items.size()) {
advanceAfterSuccessfulRound(entity, items.size());
if (isTerminal(entity.getStatus())) {
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
vo.setLoopRun(toVo(entity));
vo.setChildTaskRequest(null);
return vo;
}
shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
}
PriceTrackCreateTaskRequestVo childRequest = new PriceTrackCreateTaskRequestVo();
childRequest.setUserId(entity.getUserId());
childRequest.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
childRequest.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
childRequest.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
childRequest.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
childRequest.setItems(List.of(items.get(shopIndex)));
childRequest.setLoopRunId(entity.getId());
childRequest.setRoundIndex(Math.max(1, entity.getCurrentRound() == null ? 1 : entity.getCurrentRound()));
childRequest.setShopIndex(shopIndex);
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
vo.setLoopRun(toVo(entity));
vo.setChildTaskRequest(childRequest);
return vo;
}
@Transactional
public PriceTrackLoopRunVo childFinished(Long loopRunId, Long userId, PriceTrackLoopRunChildFinishedRequest request) {
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
handleChildFinished(entity, request.getChildTaskId(), true);
return toVo(entity);
}
@Transactional
public PriceTrackLoopRunVo requestStop(Long loopRunId, Long userId) {
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
entity.setStopRequested(true);
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getActiveTaskId() == null || isChildTaskTerminal(entity.getActiveTaskId())) {
markStopped(entity, null);
} else {
loopRunMapper.updateById(entity);
}
return toVo(entity);
}
@Transactional
public void bindChildTask(Long loopRunId, Long childTaskId, Integer roundIndex, Integer shopIndex) {
if (loopRunId == null || childTaskId == null) {
return;
}
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
if (entity == null || isTerminal(entity.getStatus())) {
return;
}
if (entity.getCurrentRound() == null || entity.getCurrentShopIndex() == null) {
return;
}
if (!entity.getCurrentRound().equals(roundIndex) || !entity.getCurrentShopIndex().equals(shopIndex)) {
return;
}
if (entity.getActiveTaskId() != null && !isChildTaskTerminal(entity.getActiveTaskId())) {
return;
}
entity.setActiveTaskId(childTaskId);
entity.setUpdatedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
}
@Transactional
public void syncLoopRunAfterChildTerminal(Long childTaskId) {
if (childTaskId == null || childTaskId <= 0) {
return;
}
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
.last("limit 5"));
for (PriceTrackLoopRunEntity entity : loops) {
handleChildFinished(entity, childTaskId, false);
}
}
@Transactional
public void syncLoopRunAfterChildRemoved(Long childTaskId) {
if (childTaskId == null || childTaskId <= 0) {
return;
}
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
.last("limit 5"));
for (PriceTrackLoopRunEntity entity : loops) {
if (entity == null || isTerminal(entity.getStatus())) {
continue;
}
entity.setActiveTaskId(null);
entity.setStatus(STATUS_STOPPED);
entity.setStopRequested(true);
entity.setErrorMessage("当前循环子任务已被删除,循环已停止");
entity.setFinishedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
}
}
private void handleChildFinished(PriceTrackLoopRunEntity entity, Long childTaskId, boolean validateOwnership) {
if (entity == null || childTaskId == null || childTaskId <= 0 || isTerminal(entity.getStatus())) {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(childTaskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("子任务不存在");
}
if (validateOwnership && !entity.getUserId().equals(task.getUserId())) {
throw new BusinessException("子任务不属于当前用户");
}
if (!isTerminal(task.getStatus())) {
throw new BusinessException("子任务尚未结束");
}
if (!belongsToLoop(task, entity.getId())) {
throw new BusinessException("子任务不属于当前循环");
}
if (entity.getActiveTaskId() != null && !entity.getActiveTaskId().equals(childTaskId)) {
return;
}
if (entity.getActiveTaskId() == null) {
ChildTaskContext ctx = parseChildTaskContext(task);
if (ctx == null
|| !ctx.roundIndex().equals(entity.getCurrentRound())
|| !ctx.shopIndex().equals(entity.getCurrentShopIndex())) {
return;
}
}
entity.setActiveTaskId(null);
entity.setUpdatedAt(LocalDateTime.now());
if (STATUS_FAILED.equals(task.getStatus())) {
entity.setStatus(STATUS_FAILED);
entity.setErrorMessage(task.getErrorMessage() == null || task.getErrorMessage().isBlank()
? "子任务执行失败"
: task.getErrorMessage());
entity.setFinishedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
return;
}
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
if (items.isEmpty()) {
entity.setStatus(STATUS_FAILED);
entity.setErrorMessage("循环任务缺少店铺配置");
entity.setFinishedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
return;
}
if (Boolean.TRUE.equals(entity.getStopRequested())) {
markStopped(entity, null);
return;
}
int nextShopIndex = (entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex()) + 1;
if (nextShopIndex < items.size()) {
entity.setCurrentShopIndex(nextShopIndex);
loopRunMapper.updateById(entity);
return;
}
advanceAfterSuccessfulRound(entity, items.size());
}
private void reconcileWithTerminalChild(PriceTrackLoopRunEntity entity) {
if (entity == null || entity.getActiveTaskId() == null || isTerminal(entity.getStatus())) {
return;
}
if (isChildTaskTerminal(entity.getActiveTaskId())) {
handleChildFinished(entity, entity.getActiveTaskId(), false);
}
}
private void advanceAfterSuccessfulRound(PriceTrackLoopRunEntity entity, int shopCount) {
if (Boolean.TRUE.equals(entity.getStopRequested())) {
markStopped(entity, null);
return;
}
if (EXECUTION_MODE_FINITE.equals(entity.getExecutionMode())) {
int targetRounds = entity.getTargetRounds() == null ? 1 : Math.max(1, entity.getTargetRounds());
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
if (currentRound >= targetRounds) {
entity.setStatus(STATUS_SUCCESS);
entity.setFinishedAt(LocalDateTime.now());
} else {
entity.setCurrentRound(currentRound + 1);
entity.setCurrentShopIndex(0);
}
} else {
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
entity.setCurrentRound(currentRound + 1);
entity.setCurrentShopIndex(0);
}
entity.setActiveTaskId(null);
entity.setUpdatedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
}
private void markStopped(PriceTrackLoopRunEntity entity, String message) {
entity.setStatus(STATUS_STOPPED);
entity.setActiveTaskId(null);
entity.setErrorMessage(message);
entity.setFinishedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
loopRunMapper.updateById(entity);
}
private boolean isChildTaskTerminal(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
return task != null && isTerminal(task.getStatus());
}
private boolean belongsToLoop(FileTaskEntity task, Long loopRunId) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return false;
}
try {
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
Object raw = payload.get("loopRunId");
if (raw instanceof Number number) {
return loopRunId.equals(number.longValue());
}
if (raw instanceof String text && !text.isBlank()) {
return loopRunId.equals(Long.parseLong(text));
}
return false;
} catch (Exception ex) {
log.warn("[price-track-loop] parse child loopRunId failed taskId={} msg={}", task.getId(), ex.getMessage());
return false;
}
}
private PriceTrackLoopRunEntity getOwnedLoopRun(Long loopRunId, Long userId) {
if (loopRunId == null || loopRunId <= 0) {
throw new BusinessException("loopRunId不合法");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id不合法");
}
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
if (entity == null || !userId.equals(entity.getUserId())) {
throw new BusinessException("循环任务不存在");
}
return entity;
}
private void validateCreateRequest(PriceTrackLoopRunCreateRequest request) {
if (request == null) {
throw new BusinessException("请求不能为空");
}
if (request.isStatusMode() == request.isAsinMode()) {
throw new BusinessException("跟价模式必须二选一");
}
if (request.isAsinMode() && (request.getAsinFiles() == null || request.getAsinFiles().isEmpty())) {
throw new BusinessException("自定义ASIN模式必须上传ASIN文件");
}
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id不合法");
}
String executionMode = normalizeExecutionMode(request.getExecutionMode());
if (EXECUTION_MODE_FINITE.equals(executionMode) && (request.getTargetRounds() == null || request.getTargetRounds() <= 0)) {
throw new BusinessException("固定轮次必须大于0");
}
}
private String normalizeExecutionMode(String raw) {
String value = raw == null ? "" : raw.trim().toUpperCase();
if (EXECUTION_MODE_FINITE.equals(value) || EXECUTION_MODE_INFINITE.equals(value)) {
return value;
}
throw new BusinessException("executionMode不合法");
}
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> rawItems) {
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> out = new LinkedHashMap<>();
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : rawItems) {
if (item == null || !Boolean.TRUE.equals(item.getMatched())) {
continue;
}
String key = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (!key.isBlank()) {
out.putIfAbsent(key, item);
}
}
return new ArrayList<>(out.values());
}
private PriceTrackLoopRunVo toVo(PriceTrackLoopRunEntity entity) {
PriceTrackLoopRunVo vo = new PriceTrackLoopRunVo();
vo.setId(entity.getId());
vo.setStatus(entity.getStatus());
vo.setExecutionMode(entity.getExecutionMode());
vo.setTargetRounds(entity.getTargetRounds());
vo.setCurrentRound(entity.getCurrentRound());
vo.setCurrentShopIndex(entity.getCurrentShopIndex());
vo.setTotalShopCount(parseShops(entity).size());
vo.setActiveTaskId(entity.getActiveTaskId());
vo.setStopRequested(Boolean.TRUE.equals(entity.getStopRequested()));
vo.setErrorMessage(entity.getErrorMessage());
vo.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
vo.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
vo.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
vo.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
if (entity.getActiveTaskId() != null) {
FileTaskEntity activeTask = fileTaskMapper.selectById(entity.getActiveTaskId());
if (activeTask != null) {
vo.setActiveTaskStatus(activeTask.getStatus());
}
}
return vo;
}
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> parseShops(PriceTrackLoopRunEntity entity) {
if (entity == null || entity.getShopsJson() == null || entity.getShopsJson().isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(entity.getShopsJson(), new TypeReference<List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem>>() {});
} catch (Exception ex) {
log.warn("[price-track-loop] parse shops failed loopRunId={} msg={}", entity.getId(), ex.getMessage());
return List.of();
}
}
private List<String> parseStringList(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(rawJson, new TypeReference<List<String>>() {});
} catch (Exception ex) {
return List.of();
}
}
private ChildTaskContext parseChildTaskContext(FileTaskEntity task) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return null;
}
try {
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
Integer roundIndex = toInteger(payload.get("roundIndex"));
Integer shopIndex = toInteger(payload.get("shopIndex"));
if (roundIndex == null || shopIndex == null) {
return null;
}
return new ChildTaskContext(roundIndex, shopIndex);
} catch (Exception ex) {
return null;
}
}
private boolean isTerminal(String status) {
return STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status) || STATUS_STOPPED.equals(status);
}
private Integer toInteger(Object raw) {
if (raw instanceof Number number) {
return number.intValue();
}
if (raw instanceof String text && !text.isBlank()) {
return Integer.parseInt(text);
}
return null;
}
private record ChildTaskContext(Integer roundIndex, Integer shopIndex) {
}
}

View File

@@ -63,6 +63,7 @@ public class PriceTrackTaskService {
private final SkipPriceAsinService skipPriceAsinService;
private final PriceTrackExcelAssemblyService excelAssemblyService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final PriceTrackLoopRunService priceTrackLoopRunService;
public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
@@ -142,6 +143,7 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
priceTrackTaskCacheService.deleteTaskCache(taskId);
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
}
@Transactional
@@ -253,6 +255,9 @@ public class PriceTrackTaskService {
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
if (request.getLoopRunId() != null) {
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
}
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
@@ -267,7 +272,10 @@ public class PriceTrackTaskService {
fr.setSuccess(0);
fr.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(fr);
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
PriceTrackResultItemVo snapshotItem = toSnapshotVo(fr, item, task.getStatus());
snapshotItem.setLoopRunId(request.getLoopRunId());
snapshotItem.setRoundIndex(request.getRoundIndex());
snapshot.add(snapshotItem);
}
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
@@ -280,6 +288,9 @@ public class PriceTrackTaskService {
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
ctx.put("asinRowsByCountry", asinRowsByCountry);
ctx.put("items", uniqueItems);
ctx.put("loopRunId", request.getLoopRunId());
ctx.put("roundIndex", request.getRoundIndex());
ctx.put("shopIndex", request.getShopIndex());
task.setRequestJson(objectMapper.writeValueAsString(ctx));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) {
@@ -441,6 +452,7 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
return;
@@ -768,6 +780,7 @@ public class PriceTrackTaskService {
vo.setCreatedAt(fmt(task.getCreatedAt()));
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
vo.setFinishedAt(fmt(task.getFinishedAt()));
mergeTaskLoopFields(vo, task);
return vo;
}
@@ -794,10 +807,34 @@ public class PriceTrackTaskService {
break;
}
}
mergeLoopFields(payload, vo);
} catch (Exception ignored) {
}
}
private void mergeTaskLoopFields(PriceTrackTaskItemVo vo, FileTaskEntity task) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
try {
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
Object rawLoopRunId = payload.get("loopRunId");
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
Object rawRoundIndex = payload.get("roundIndex");
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
} catch (Exception ignored) {
}
}
private void mergeLoopFields(Map<String, Object> payload, PriceTrackResultItemVo vo) {
Object rawLoopRunId = payload.get("loopRunId");
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
Object rawRoundIndex = payload.get("roundIndex");
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
}
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
@@ -867,6 +904,9 @@ public class PriceTrackTaskService {
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
priceTrackLoopRunService.syncLoopRunAfterChildTerminal(task.getId());
}
}
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS biz_price_track_loop_run (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
user_id BIGINT NOT NULL COMMENT 'user id',
status VARCHAR(32) NOT NULL COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/STOPPED',
execution_mode VARCHAR(16) NOT NULL COMMENT 'FINITE/INFINITE',
target_rounds INT NULL COMMENT 'configured rounds for finite mode',
current_round INT NOT NULL DEFAULT 1 COMMENT '1-based current round',
current_shop_index INT NOT NULL DEFAULT 0 COMMENT '0-based current shop index',
status_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'status mode enabled',
asin_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'asin mode enabled',
asin_files_json MEDIUMTEXT NULL COMMENT 'asin file paths json',
country_codes_json MEDIUMTEXT NOT NULL COMMENT 'country codes json',
shops_json MEDIUMTEXT NOT NULL COMMENT 'shop queue items json',
active_task_id BIGINT NULL COMMENT 'currently running child task id',
stop_requested TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'manual stop requested',
error_message VARCHAR(1000) NULL COMMENT 'terminal error message',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time',
finished_at DATETIME NULL COMMENT 'finished time',
KEY idx_user_status (user_id, status),
KEY idx_active_task_id (active_task_id)
) COMMENT='price track loop runs';