改造后台权限和APP权限

This commit is contained in:
super
2026-04-19 15:06:09 +08:00
parent d5764f3b50
commit 8db9f3a408
23 changed files with 1343 additions and 36 deletions

View File

@@ -267,7 +267,8 @@ def admin_delete_user(uid):
@login_required @login_required
def admin_user_column_permissions(uid): def admin_user_column_permissions(uid):
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。""" """获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
current_uid = session.get('user_id') current_uid = session.get('user_id')
menu_type = (request.args.get('menu_type') or '').strip().lower()
if current_uid != uid: if current_uid != uid:
role, _ = _get_current_admin_role() role, _ = _get_current_admin_role()
if not role: if not role:
@@ -277,28 +278,44 @@ def admin_user_column_permissions(uid):
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE id = %s", (uid,)) cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
user_row = cur.fetchone() user_row = cur.fetchone()
if user_row and (user_row.get('role') or '').strip() == 'super_admin': valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
cur.execute(""" if user_row and (user_row.get('role') or '').strip() == 'super_admin':
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
rows = cur.fetchall() FROM columns
else: """
cur.execute(""" params = []
SELECT c.id, c.name, c.column_key, c.created_at if valid_menu_type:
FROM columns c sql += " WHERE menu_type = %s"
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id params.append(valid_menu_type)
WHERE ucp.user_id = %s sql += " ORDER BY sort_order ASC, id ASC"
ORDER BY c.id cur.execute(sql, tuple(params))
""", (uid,)) rows = cur.fetchall()
rows = cur.fetchall() else:
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
"""
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() conn.close()
items = [ items = [
{ {
'id': r['id'], 'id': r['id'],
'name': r['name'], 'name': r['name'],
'column_key': r['column_key'], 'column_key': r['column_key'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', '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 for r in rows
] ]
return jsonify({'success': True, 'items': items}) return jsonify({'success': True, 'items': items})

View File

@@ -50,10 +50,10 @@ def wb():
return _render_html('index.html') return _render_html('index.html')
@main_bp.route('/brand') @main_bp.route('/brand')
@login_required @login_required
def brand_page(): def brand_page():
return _render_html('brand.html') return _render_html('brand.html', user_id=session.get('user_id'))
@@ -161,4 +161,4 @@ def proxy(path):
return Response("无法连接到后端服务", status=502) return Response("无法连接到后端服务", status=502)
except Exception as e: except Exception as e:
print(f"代理请求失败: {e}") print(f"代理请求失败: {e}")
return Response(f"代理错误: {str(e)}", status=500) return Response(f"代理错误: {str(e)}", status=500)

View File

@@ -15,6 +15,12 @@ public class PermissionMenuSchemaInitializer {
private final JdbcTemplate jdbcTemplate; 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( private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
new DefaultAdminMenu("用户管理", "admin_users", "users", 10), new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20), 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("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)"); executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
ensureDefaultAdminMenus(); ensureDefaultAdminMenus();
ensureDefaultAppMenus();
executeQuietly(""" executeQuietly("""
CREATE TABLE IF NOT EXISTS user_column_permission ( CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL, 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) { private void executeQuietly(String sql) {
try { try {
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
@@ -82,4 +106,7 @@ public class PermissionMenuSchemaInitializer {
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) { 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.PriceTrackCandidateAddRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; 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.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.PriceTrackMatchShopsRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest; 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.PriceTrackCreateTaskVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo; 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.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.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo; import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo; 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.PriceTrackService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -53,6 +58,7 @@ public class PriceTrackController {
private final PriceTrackService priceTrackService; private final PriceTrackService priceTrackService;
private final PriceTrackTaskService priceTrackTaskService; private final PriceTrackTaskService priceTrackTaskService;
private final PriceTrackLoopRunService priceTrackLoopRunService;
@GetMapping("/candidates") @GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。") @Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
@@ -131,6 +137,45 @@ public class PriceTrackController {
return ApiResponse.success(priceTrackTaskService.createTask(request)); 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}") @DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行RUNNING、SUCCESS、FAILED 均可删除。") @Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行RUNNING、SUCCESS、FAILED 均可删除。")
public ApiResponse<Void> deleteTask( 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 = "国家列表不能为空") @NotEmpty(message = "国家列表不能为空")
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]") @Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
private List<String> countryCodes; 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列表可能带回也可通过下载接口获取") @Schema(description = "预签名下载 URL列表可能带回也可通过下载接口获取")
private String downloadUrl; 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 = "完成时间;未结束时为空") @Schema(description = "完成时间;未结束时为空")
private String finishedAt; 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 SkipPriceAsinService skipPriceAsinService;
private final PriceTrackExcelAssemblyService excelAssemblyService; private final PriceTrackExcelAssemblyService excelAssemblyService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService; private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final PriceTrackLoopRunService priceTrackLoopRunService;
public PriceTrackDashboardVo dashboard(Long userId) { public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
@@ -142,6 +143,7 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE)); .eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId); fileTaskMapper.deleteById(taskId);
priceTrackTaskCacheService.deleteTaskCache(taskId); priceTrackTaskCacheService.deleteTaskCache(taskId);
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
} }
@Transactional @Transactional
@@ -253,6 +255,9 @@ public class PriceTrackTaskService {
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task); fileTaskMapper.insert(task);
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId()); priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
if (request.getLoopRunId() != null) {
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
}
List<PriceTrackResultItemVo> snapshot = new ArrayList<>(); List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) { for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
@@ -267,7 +272,10 @@ public class PriceTrackTaskService {
fr.setSuccess(0); fr.setSuccess(0);
fr.setCreatedAt(LocalDateTime.now()); fr.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(fr); 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 消费和前端轮询展示使用 // 保存请求上下文快照,供 Python 消费和前端轮询展示使用
@@ -280,6 +288,9 @@ public class PriceTrackTaskService {
ctx.put("skipAsinsByCountry", skipAsinsByCountry); ctx.put("skipAsinsByCountry", skipAsinsByCountry);
ctx.put("asinRowsByCountry", asinRowsByCountry); ctx.put("asinRowsByCountry", asinRowsByCountry);
ctx.put("items", uniqueItems); 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.setRequestJson(objectMapper.writeValueAsString(ctx));
task.setResultJson(objectMapper.writeValueAsString(snapshot)); task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) { } catch (Exception ex) {
@@ -441,6 +452,7 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId)); .orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) { if (latest.isEmpty()) {
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
fileTaskMapper.deleteById(taskId); fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY"); cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
return; return;
@@ -768,6 +780,7 @@ public class PriceTrackTaskService {
vo.setCreatedAt(fmt(task.getCreatedAt())); vo.setCreatedAt(fmt(task.getCreatedAt()));
vo.setUpdatedAt(fmt(task.getUpdatedAt())); vo.setUpdatedAt(fmt(task.getUpdatedAt()));
vo.setFinishedAt(fmt(task.getFinishedAt())); vo.setFinishedAt(fmt(task.getFinishedAt()));
mergeTaskLoopFields(vo, task);
return vo; return vo;
} }
@@ -794,10 +807,34 @@ public class PriceTrackTaskService {
break; break;
} }
} }
mergeLoopFields(payload, vo);
} catch (Exception ignored) { } 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) { private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList( List<FileResultEntity> rows = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>() 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={}", 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()); task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus()); 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) { 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';

View File

@@ -20,6 +20,24 @@
</div> </div>
<!-- 按指定ASIN文档上传 --> <!-- 按指定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"> <template v-if="asinModeEnabled">
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div> <div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
<div class="upload-zone"> <div class="upload-zone">
@@ -101,12 +119,23 @@
<button type="button" class="btn-run" :disabled="matching" @click="runMatch"> <button type="button" class="btn-run" :disabled="matching" @click="runMatch">
{{ matching ? '匹配中' : '匹配店铺' }} {{ matching ? '匹配中' : '匹配店铺' }}
</button> </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 队列' }} {{ pushing ? '推送中' : '推送到 Python 队列' }}
</button> </button>
</div> </div>
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>跟价结果合并输出全量和自定义的区别仅在于ASIN来源不同</p> <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 v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div> <div class="section-title queue-debug-title">推送结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div> <div class="queue-debug-line">{{ queuePushResult }}</div>
@@ -176,6 +205,7 @@
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`" <li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"> class="task-item split-result-item">
<div class="left split-result-main"> <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> <span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</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}`" <li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"> class="task-item split-result-item">
<div class="left split-result-main"> <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> <span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</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 { getPywebviewApi } from '@/shared/bridges/pywebview'
import { import {
addPriceTrackCandidate, addPriceTrackCandidate,
completePriceTrackLoopChild,
createPriceTrackTask, createPriceTrackTask,
createPriceTrackLoopRun,
dispatchNextPriceTrackLoopRun,
deletePendingPriceTrackShopResult, deletePendingPriceTrackShopResult,
deletePriceTrackCandidate, deletePriceTrackCandidate,
deletePriceTrackHistory, deletePriceTrackHistory,
@@ -238,17 +272,21 @@ import {
getPriceTrackCountryPreference, getPriceTrackCountryPreference,
getPriceTrackDashboard, getPriceTrackDashboard,
getPriceTrackHistory, getPriceTrackHistory,
getPriceTrackLoopRun,
getPriceTrackResultDownloadUrl, getPriceTrackResultDownloadUrl,
getPriceTrackTaskProgressBatch, getPriceTrackTaskProgressBatch,
getPriceTrackTasksBatch, getPriceTrackTasksBatch,
listPriceTrackCandidates, listPriceTrackCandidates,
matchPriceTrackShops, matchPriceTrackShops,
putPriceTrackCountryPreference, putPriceTrackCountryPreference,
stopPriceTrackLoopRun,
type PriceTrackAsinParsedRow, type PriceTrackAsinParsedRow,
type PriceTrackCandidateVo, type PriceTrackCandidateVo,
type PriceTrackCreateTaskVo, type PriceTrackCreateTaskVo,
type PriceTrackDashboardVo, type PriceTrackDashboardVo,
type PriceTrackExecutionMode,
type PriceTrackHistoryItem, type PriceTrackHistoryItem,
type PriceTrackLoopRunVo,
type PriceTrackShopQueueItem, type PriceTrackShopQueueItem,
type PriceTrackTaskDetailVo, type PriceTrackTaskDetailVo,
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
@@ -273,9 +311,15 @@ const pushing = ref(false)
const queuePushResult = ref('') const queuePushResult = ref('')
const queuePayloadText = ref('') const queuePayloadText = ref('')
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({}) 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文档自定义 const asinModeEnabled = ref(false) // 按指定ASIN文档自定义
// 上传文件 // 上传文件
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
// 是否有有效模式 // 是否有有效模式
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value) 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 orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
const dragCountryIndex = ref<number | null>(null) const dragCountryIndex = ref<number | null>(null)
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
return `price-track:matched-items:${uidForStorage()}` return `price-track:matched-items:${uidForStorage()}`
} }
function activeLoopStorageKey() {
return `price-track:active-loop:${uidForStorage()}`
}
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) { function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { try {
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) 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() { async function selectAsinFile() {
const api = getPywebviewApi() 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 }>) { function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
if (!rows.length) return if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row))) 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() saveMatchedItemsToStorage()
} }
async function removeMatchedRow(row: PriceTrackShopQueueItem) { async function removeMatchedRow(row: PriceTrackShopQueueItem) {
const key = rowKeyForMatch(row)
const backup = [...matchedItems.value] const backup = [...matchedItems.value]
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key) removeMatchedRowsLocally([row])
saveMatchedItemsToStorage()
const name = (row.shopName || '').trim() const name = (row.shopName || '').trim()
if (!name) { if (!name) {
ElMessage.success('已从匹配结果中移除') 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() { function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 6000 : 20000 return document.visibilityState === 'visible' ? 6000 : 20000
} }
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
} else if (item.taskId != null) { } else if (item.taskId != null) {
await deletePriceTrackTask(item.taskId) 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) if (item.taskId != null) removePollingTask(item.taskId)
removeMatchedRowsLocally([item]) removeMatchedRowsLocally([item])
await loadHistory() await loadHistory()
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => { onMounted(() => {
if (!statusModeEnabled.value && !asinModeEnabled.value) {
selectMode('status')
}
loadPollingIdsFromStorage() loadPollingIdsFromStorage()
loadTaskDetailsFromStorage() loadTaskDetailsFromStorage()
loadTaskSnapshotsFromStorage() loadTaskSnapshotsFromStorage()
loadMatchedItemsFromStorage() loadMatchedItemsFromStorage()
loadActiveLoopRunIdFromStorage()
loadCandidates().catch(() => undefined) loadCandidates().catch(() => undefined)
loadCountryPreference().catch(() => undefined) loadCountryPreference().catch(() => undefined)
loadHistory().catch(() => undefined) loadHistory().catch(() => undefined)
loadDashboard().catch(() => undefined) loadDashboard().catch(() => undefined)
if (pollingTaskIds.value.length) scheduleNextPoll(true) 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(() => { onUnmounted(() => {
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
font-size: 12px; 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 { .tag-badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
line-height: 1.5; 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 { .loading-msg code {
font-size: 11px; font-size: 11px;
color: #8fd3ff; color: #8fd3ff;

View File

@@ -7,8 +7,8 @@
<nav class="nav-sections" aria-label="顶部功能导航"> <nav class="nav-sections" aria-label="顶部功能导航">
<section <section
v-for="group in navGroups" v-for="group in visibleNavGroups"
:key="group.label" :key="group.columnKey"
class="nav-section" class="nav-section"
> >
<div class="nav-section-title">{{ group.label }}</div> <div class="nav-section-title">{{ group.label }}</div>
@@ -39,6 +39,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
type ActiveNavKey = type ActiveNavKey =
| 'brand' | 'brand'
| 'dedupe' | 'dedupe'
@@ -55,14 +59,22 @@ type NavItem = {
href?: string href?: string
} }
type NavGroup = {
columnKey: string
label: string
items: ReadonlyArray<NavItem>
}
const props = defineProps<{ const props = defineProps<{
active: ActiveNavKey active: ActiveNavKey
}>() }>()
const active = props.active 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: '前端工具', label: '前端工具',
items: [ items: [
{ key: 'collect', label: '采集数据' }, { key: 'collect', label: '采集数据' },
@@ -74,6 +86,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
], ],
}, },
{ {
columnKey: 'brand_operation_tools',
label: '运营工具', label: '运营工具',
items: [ items: [
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' }, { 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: '后勤工具', label: '后勤工具',
items: [ items: [
{ key: 'purchase', label: '采购' }, { 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> </script>
<style scoped> <style scoped>

View File

@@ -955,6 +955,8 @@ export interface PriceTrackDashboardVo {
export interface PriceTrackHistoryItem { export interface PriceTrackHistoryItem {
resultId?: number; resultId?: number;
taskId?: number; taskId?: number;
loopRunId?: number;
roundIndex?: number;
shopName?: string; shopName?: string;
shopId?: number | string | null; shopId?: number | string | null;
platform?: string; platform?: string;
@@ -998,6 +1000,8 @@ export interface PriceTrackTaskSummary {
id?: number; id?: number;
taskNo?: string; taskNo?: string;
status?: string; status?: string;
loopRunId?: number;
roundIndex?: number;
errorMessage?: string; errorMessage?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -1146,6 +1150,45 @@ export interface PriceTrackCreateTaskRequest {
items: PriceTrackShopQueueItem[]; items: PriceTrackShopQueueItem[];
asinFiles: string[]; asinFiles: string[];
countryCodes: 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( 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[]) { export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse( return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>( post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(

View 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)
}