改造后台权限和APP权限
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -268,6 +268,7 @@ def admin_delete_user(uid):
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
menu_type = (request.args.get('menu_type') or '').strip().lower()
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
@@ -277,19 +278,32 @@ def admin_user_column_permissions(uid):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
cur.execute("""
|
||||
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
||||
""")
|
||||
sql = """
|
||||
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
|
||||
FROM columns
|
||||
"""
|
||||
params = []
|
||||
if valid_menu_type:
|
||||
sql += " WHERE menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY sort_order ASC, id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.column_key, c.created_at
|
||||
sql = """
|
||||
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
ORDER BY c.id
|
||||
""", (uid,))
|
||||
"""
|
||||
params = [uid]
|
||||
if valid_menu_type:
|
||||
sql += " AND c.menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY c.sort_order ASC, c.id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
@@ -297,6 +311,9 @@ def admin_user_column_permissions(uid):
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'menu_type': r.get('menu_type') or 'app',
|
||||
'route_path': r.get('route_path') or '',
|
||||
'sort_order': r.get('sort_order') or 0,
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
|
||||
@@ -53,7 +53,7 @@ def wb():
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
return _render_html('brand.html')
|
||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -20,6 +20,24 @@
|
||||
</div>
|
||||
|
||||
<!-- 按指定ASIN文档:上传 -->
|
||||
<div class="section-title">执行配置</div>
|
||||
<div class="mode-zone">
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="FINITE" />
|
||||
<span class="mode-check-text">执行固定轮次</span>
|
||||
<span class="mode-hint-inline">按整批店铺重复执行指定次数</span>
|
||||
</label>
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="INFINITE" />
|
||||
<span class="mode-check-text">无限执行</span>
|
||||
<span class="mode-hint-inline">上一轮全部成功后立即开始下一轮</span>
|
||||
</label>
|
||||
<div v-if="executionMode === 'FINITE'" class="loop-round-input">
|
||||
<span class="hint">轮次</span>
|
||||
<el-input-number v-model="roundCount" :min="1" :max="9999" :disabled="hasRunningLoop || pushing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="asinModeEnabled">
|
||||
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
|
||||
<div class="upload-zone">
|
||||
@@ -101,12 +119,23 @@
|
||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonQueue">
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||
|
||||
<div v-if="activeLoopRun" class="loop-status-card">
|
||||
<div class="loop-status-line">循环任务 ID:{{ activeLoopRun.id }}</div>
|
||||
<div class="loop-status-line">状态:{{ activeLoopRun.status || '-' }}</div>
|
||||
<div class="loop-status-line">进度:{{ loopProgressText }}</div>
|
||||
<div class="loop-status-line">当前店铺:{{ ((activeLoopRun.currentShopIndex || 0) + 1) }}/{{ activeLoopRun.totalShopCount || 0 }}</div>
|
||||
<div v-if="activeLoopRun.errorMessage" class="loop-status-line loop-error">{{ activeLoopRun.errorMessage }}</div>
|
||||
<button type="button" class="opt-btn loop-stop-btn" :disabled="!hasRunningLoop || stopLoopPending" @click="stopLoopExecution">
|
||||
{{ stopLoopPending ? '停止中...' : '停止循环' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">推送结果</div>
|
||||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||||
@@ -176,6 +205,7 @@
|
||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||
@@ -198,6 +228,7 @@
|
||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
||||
@@ -230,7 +261,10 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
createPriceTrackTask,
|
||||
createPriceTrackLoopRun,
|
||||
dispatchNextPriceTrackLoopRun,
|
||||
deletePendingPriceTrackShopResult,
|
||||
deletePriceTrackCandidate,
|
||||
deletePriceTrackHistory,
|
||||
@@ -238,17 +272,21 @@ import {
|
||||
getPriceTrackCountryPreference,
|
||||
getPriceTrackDashboard,
|
||||
getPriceTrackHistory,
|
||||
getPriceTrackLoopRun,
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTaskProgressBatch,
|
||||
getPriceTrackTasksBatch,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
stopPriceTrackLoopRun,
|
||||
type PriceTrackAsinParsedRow,
|
||||
type PriceTrackCandidateVo,
|
||||
type PriceTrackCreateTaskVo,
|
||||
type PriceTrackDashboardVo,
|
||||
type PriceTrackExecutionMode,
|
||||
type PriceTrackHistoryItem,
|
||||
type PriceTrackLoopRunVo,
|
||||
type PriceTrackShopQueueItem,
|
||||
type PriceTrackTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
@@ -273,9 +311,15 @@ const pushing = ref(false)
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||
const executionMode = ref<PriceTrackExecutionMode>('FINITE')
|
||||
const roundCount = ref(1)
|
||||
const activeLoopRunId = ref<number | null>(null)
|
||||
const activeLoopRun = ref<PriceTrackLoopRunVo | null>(null)
|
||||
const loopDispatching = ref(false)
|
||||
const stopLoopPending = ref(false)
|
||||
|
||||
// 跟价模式(可同时启用)
|
||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
||||
const statusModeEnabled = ref(true) // 按商品状态(全量)
|
||||
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||
|
||||
// 上传文件
|
||||
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
|
||||
|
||||
// 是否有有效模式
|
||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||
const loopIsTerminal = computed(() => {
|
||||
const status = activeLoopRun.value?.status || ''
|
||||
return status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED'
|
||||
})
|
||||
const hasRunningLoop = computed(() => !!activeLoopRunId.value && !loopIsTerminal.value)
|
||||
const loopProgressText = computed(() => {
|
||||
const loop = activeLoopRun.value
|
||||
if (!loop) return ''
|
||||
const round = loop.currentRound || 1
|
||||
if (loop.executionMode === 'INFINITE') return `第 ${round} 轮(无限)`
|
||||
const total = Math.max(1, loop.targetRounds || roundCount.value || 1)
|
||||
return `第 ${round}/${total} 轮`
|
||||
})
|
||||
|
||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
|
||||
return `price-track:matched-items:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function activeLoopStorageKey() {
|
||||
return `price-track:active-loop:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
|
||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||
}
|
||||
|
||||
function loadActiveLoopRunIdFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(activeLoopStorageKey()) : null
|
||||
if (!raw) {
|
||||
activeLoopRunId.value = null
|
||||
return
|
||||
}
|
||||
const parsed = Number(JSON.parse(raw))
|
||||
activeLoopRunId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
} catch {
|
||||
activeLoopRunId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function saveActiveLoopRunId() {
|
||||
setStorageJson(activeLoopStorageKey(), activeLoopRunId.value, !activeLoopRunId.value)
|
||||
}
|
||||
|
||||
// ========== 文件上传 ==========
|
||||
async function selectAsinFile() {
|
||||
const api = getPywebviewApi()
|
||||
@@ -704,15 +783,18 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
||||
const names = new Set(rows.map((row) => (row.shopName || '').trim()).filter((name) => !!name))
|
||||
matchedItems.value = matchedItems.value.filter((row) => {
|
||||
const key = rowKeyForMatch(row)
|
||||
const name = (row.shopName || '').trim()
|
||||
return !keys.has(key) && (!name || !names.has(name))
|
||||
})
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
const key = rowKeyForMatch(row)
|
||||
const backup = [...matchedItems.value]
|
||||
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
|
||||
saveMatchedItemsToStorage()
|
||||
removeMatchedRowsLocally([row])
|
||||
const name = (row.shopName || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
@@ -942,6 +1024,185 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
function setLoopRun(next: PriceTrackLoopRunVo | null) {
|
||||
activeLoopRun.value = next
|
||||
activeLoopRunId.value = next?.id ?? null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
|
||||
function clearLoopRunIfTerminal() {
|
||||
if (!activeLoopRun.value) return
|
||||
const status = activeLoopRun.value.status || ''
|
||||
if (status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED') {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncActiveLoopRun() {
|
||||
if (!activeLoopRunId.value) return null
|
||||
try {
|
||||
const loop = await getPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
return activeLoopRun.value
|
||||
} catch {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
}
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
}
|
||||
|
||||
async function stopLoopExecution() {
|
||||
if (!activeLoopRunId.value || stopLoopPending.value) return
|
||||
stopLoopPending.value = true
|
||||
try {
|
||||
const loop = await stopPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
ElMessage.success(loop.status === 'STOPPED' ? '循环已停止' : '已请求停止,当前子任务完成后将收尾')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止循环失败')
|
||||
} finally {
|
||||
stopLoopPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoopExecution(loopId?: number) {
|
||||
if (loopDispatching.value) return
|
||||
if (loopId && activeLoopRunId.value !== loopId) {
|
||||
activeLoopRunId.value = loopId
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
if (!activeLoopRunId.value) return
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
throw new Error('当前环境未启用 pywebview enqueue_json')
|
||||
}
|
||||
loopDispatching.value = true
|
||||
try {
|
||||
while (activeLoopRunId.value) {
|
||||
const loop = await syncActiveLoopRun()
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
if (loop.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
|
||||
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
|
||||
setLoopRun(updatedLoop)
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
clearLoopRunIfTerminal()
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
setLoopRun(dispatch.loopRun || loop)
|
||||
if (!dispatch.childTaskRequest) {
|
||||
clearLoopRunIfTerminal()
|
||||
break
|
||||
}
|
||||
const row = dispatch.childTaskRequest.items?.[0]
|
||||
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||||
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
|
||||
recordCreatedTask(taskVo)
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
|
||||
}
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
removeMatchedRowsLocally([row])
|
||||
queuePushResult.value = `循环任务 ${loop.id} 第 ${dispatch.childTaskRequest.roundIndex || 1} 轮已推送:${row.shopName || taskVo.taskId}`
|
||||
}
|
||||
} finally {
|
||||
loopDispatching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonLoopQueue() {
|
||||
if (hasRunningLoop.value) {
|
||||
try {
|
||||
await runLoopExecution(activeLoopRunId.value || undefined)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /子任务不存在|循环任务不存在|404/.test(error.message)) {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '循环执行失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!hasValidMode.value) {
|
||||
ElMessage.warning('请至少选择一个跟价模式')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
const matchedRows = matchedItems.value.filter((item) => item.matched)
|
||||
if (!matchedRows.length) {
|
||||
ElMessage.warning('请先匹配可用店铺')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
queuePayloadText.value = ''
|
||||
try {
|
||||
const loop = await createPriceTrackLoopRun({
|
||||
userId: 0,
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows,
|
||||
asinFiles: asinFiles.value,
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
executionMode: executionMode.value,
|
||||
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||
})
|
||||
setLoopRun(loop)
|
||||
queuePushResult.value = `循环任务 ${loop.id} 已创建`
|
||||
await runLoopExecution(loop.id)
|
||||
clearLoopRunIfTerminal()
|
||||
if (!activeLoopRunId.value) {
|
||||
ElMessage.success('跟价循环已启动')
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '启动循环失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
}
|
||||
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
} else if (item.taskId != null) {
|
||||
await deletePriceTrackTask(item.taskId)
|
||||
}
|
||||
if (activeLoopRun.value && ((item.loopRunId != null && item.loopRunId === activeLoopRun.value.id) || (item.taskId != null && item.taskId === activeLoopRun.value.activeTaskId))) {
|
||||
await syncActiveLoopRun()
|
||||
}
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
removeMatchedRowsLocally([item])
|
||||
await loadHistory()
|
||||
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
if (!statusModeEnabled.value && !asinModeEnabled.value) {
|
||||
selectMode('status')
|
||||
}
|
||||
loadPollingIdsFromStorage()
|
||||
loadTaskDetailsFromStorage()
|
||||
loadTaskSnapshotsFromStorage()
|
||||
loadMatchedItemsFromStorage()
|
||||
loadActiveLoopRunIdFromStorage()
|
||||
loadCandidates().catch(() => undefined)
|
||||
loadCountryPreference().catch(() => undefined)
|
||||
loadHistory().catch(() => undefined)
|
||||
loadDashboard().catch(() => undefined)
|
||||
if (pollingTaskIds.value.length) scheduleNextPoll(true)
|
||||
if (activeLoopRunId.value) {
|
||||
void syncActiveLoopRun().then((loop) => {
|
||||
if (loop?.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
if (loop && loop.status !== 'SUCCESS' && loop.status !== 'FAILED' && loop.status !== 'STOPPED') {
|
||||
void runLoopExecution(loop.id)
|
||||
}
|
||||
}).catch(() => {
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loop-round-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input-number) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input__wrapper) {
|
||||
background-color: #2a2a2a;
|
||||
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loop-status-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2f3d2f;
|
||||
background: rgba(39, 174, 96, 0.08);
|
||||
}
|
||||
|
||||
.loop-status-line {
|
||||
font-size: 12px;
|
||||
color: #d6e6d8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loop-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.loop-stop-btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.loading-msg code {
|
||||
font-size: 11px;
|
||||
color: #8fd3ff;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<nav class="nav-sections" aria-label="顶部功能导航">
|
||||
<section
|
||||
v-for="group in navGroups"
|
||||
:key="group.label"
|
||||
v-for="group in visibleNavGroups"
|
||||
:key="group.columnKey"
|
||||
class="nav-section"
|
||||
>
|
||||
<div class="nav-section-title">{{ group.label }}</div>
|
||||
@@ -39,6 +39,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
| 'dedupe'
|
||||
@@ -55,14 +59,22 @@ type NavItem = {
|
||||
href?: string
|
||||
}
|
||||
|
||||
type NavGroup = {
|
||||
columnKey: string
|
||||
label: string
|
||||
items: ReadonlyArray<NavItem>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
active: ActiveNavKey
|
||||
}>()
|
||||
|
||||
const active = props.active
|
||||
const allowedColumnKeys = ref<string[] | null>(null)
|
||||
|
||||
const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }> = [
|
||||
const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{
|
||||
columnKey: 'brand_front_tools',
|
||||
label: '前端工具',
|
||||
items: [
|
||||
{ key: 'collect', label: '采集数据' },
|
||||
@@ -74,6 +86,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'brand_operation_tools',
|
||||
label: '运营工具',
|
||||
items: [
|
||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
||||
@@ -86,6 +99,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'brand_logistics_tools',
|
||||
label: '后勤工具',
|
||||
items: [
|
||||
{ key: 'purchase', label: '采购' },
|
||||
@@ -93,6 +107,22 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleNavGroups = computed(() => {
|
||||
if (allowedColumnKeys.value === null) {
|
||||
return navGroups
|
||||
}
|
||||
const allowedSet = new Set(allowedColumnKeys.value)
|
||||
return navGroups.filter((group) => allowedSet.has(group.columnKey))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
allowedColumnKeys.value = await getCurrentUserAppColumnKeys()
|
||||
} catch (_error) {
|
||||
allowedColumnKeys.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -955,6 +955,8 @@ export interface PriceTrackDashboardVo {
|
||||
export interface PriceTrackHistoryItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
shopName?: string;
|
||||
shopId?: number | string | null;
|
||||
platform?: string;
|
||||
@@ -998,6 +1000,8 @@ export interface PriceTrackTaskSummary {
|
||||
id?: number;
|
||||
taskNo?: string;
|
||||
status?: string;
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -1146,6 +1150,45 @@ export interface PriceTrackCreateTaskRequest {
|
||||
items: PriceTrackShopQueueItem[];
|
||||
asinFiles: string[];
|
||||
countryCodes: string[];
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
shopIndex?: number;
|
||||
}
|
||||
|
||||
export type PriceTrackExecutionMode = "FINITE" | "INFINITE";
|
||||
|
||||
export interface PriceTrackLoopRunCreateRequest {
|
||||
userId: number;
|
||||
statusMode: boolean;
|
||||
asinMode: boolean;
|
||||
items: PriceTrackShopQueueItem[];
|
||||
asinFiles: string[];
|
||||
countryCodes: string[];
|
||||
executionMode: PriceTrackExecutionMode;
|
||||
targetRounds?: number;
|
||||
}
|
||||
|
||||
export interface PriceTrackLoopRunVo {
|
||||
id: number;
|
||||
status?: string;
|
||||
executionMode?: PriceTrackExecutionMode;
|
||||
targetRounds?: number;
|
||||
currentRound?: number;
|
||||
currentShopIndex?: number;
|
||||
totalShopCount?: number;
|
||||
activeTaskId?: number;
|
||||
activeTaskStatus?: string;
|
||||
stopRequested?: boolean;
|
||||
errorMessage?: string;
|
||||
statusMode?: boolean;
|
||||
asinMode?: boolean;
|
||||
asinFiles?: string[];
|
||||
countryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface PriceTrackLoopRunDispatchVo {
|
||||
loopRun?: PriceTrackLoopRunVo;
|
||||
childTaskRequest?: PriceTrackCreateTaskRequest;
|
||||
}
|
||||
|
||||
export function createPriceTrackTask(
|
||||
@@ -1176,6 +1219,52 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createPriceTrackLoopRun(
|
||||
request: Omit<PriceTrackLoopRunCreateRequest, "userId"> | PriceTrackLoopRunCreateRequest,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, PriceTrackLoopRunCreateRequest>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs`,
|
||||
{ ...request, userId: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackLoopRunVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function dispatchNextPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunDispatchVo>, undefined>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/dispatch-next?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function completePriceTrackLoopChild(loopRunId: number, childTaskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, { childTaskId: number }>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/child-finished?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
{ childTaskId },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function stopPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, undefined>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/stop?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||
|
||||
63
frontend-vue/src/shared/api/permission.ts
Normal file
63
frontend-vue/src/shared/api/permission.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { requestGetJson } from '@/shared/api/http'
|
||||
|
||||
export interface PermissionMenuItem {
|
||||
id: number | string
|
||||
name?: string
|
||||
column_key?: string
|
||||
route_path?: string
|
||||
menu_type?: string
|
||||
sort_order?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface PermissionMenuResponse {
|
||||
success: boolean
|
||||
items?: PermissionMenuItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
function getCurrentUserId() {
|
||||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('未获取到用户ID')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getAppPermissionCacheKey(uid: number) {
|
||||
return `app_column_permissions:${String(uid)}`
|
||||
}
|
||||
|
||||
export async function getCurrentUserAppColumnKeys() {
|
||||
const uid = getCurrentUserId()
|
||||
const cacheKey = getAppPermissionCacheKey(uid)
|
||||
|
||||
try {
|
||||
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
|
||||
if (Array.isArray(cachedItems)) {
|
||||
return cachedItems
|
||||
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch (_error) {}
|
||||
|
||||
const res = await requestGetJson<PermissionMenuResponse>(
|
||||
`/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`,
|
||||
{
|
||||
params: { menu_type: 'app' },
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.error || '获取菜单权限失败')
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(res.items || []))
|
||||
} catch (_error) {}
|
||||
|
||||
return (res.items || [])
|
||||
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
Reference in New Issue
Block a user