改造后台权限和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
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:
role, _ = _get_current_admin_role()
if not role:
@@ -277,28 +278,44 @@ 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()
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
""")
rows = cur.fetchall()
else:
cur.execute("""
SELECT c.id, c.name, c.column_key, 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,))
rows = cur.fetchall()
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
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:
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()
items = [
{
'id': r['id'],
'name': r['name'],
'column_key': r['column_key'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
{
'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
]
return jsonify({'success': True, 'items': items})

View File

@@ -50,10 +50,10 @@ def wb():
return _render_html('index.html')
@main_bp.route('/brand')
@login_required
def brand_page():
return _render_html('brand.html')
@main_bp.route('/brand')
@login_required
def brand_page():
return _render_html('brand.html', user_id=session.get('user_id'))
@@ -161,4 +161,4 @@ def proxy(path):
return Response("无法连接到后端服务", status=502)
except Exception as 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 static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
);
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
@@ -44,6 +50,7 @@ public class PermissionMenuSchemaInitializer {
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
ensureDefaultAdminMenus();
ensureDefaultAppMenus();
executeQuietly("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
@@ -72,6 +79,23 @@ public class PermissionMenuSchemaInitializer {
}
}
private void ensureDefaultAppMenus() {
for (DefaultAppMenu menu : DEFAULT_APP_MENUS) {
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
SELECT '%s', '%s', 'app', '%s', %d
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = '%s'
)
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
executeQuietly("""
UPDATE columns
SET sort_order = %d, route_path = '%s', menu_type = 'app'
WHERE column_key = '%s'
""".formatted(menu.sortOrder(), menu.routePath(), menu.columnKey()));
}
}
private void executeQuietly(String sql) {
try {
jdbcTemplate.execute(sql);
@@ -82,4 +106,7 @@ public class PermissionMenuSchemaInitializer {
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
}
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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[] }>(

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