diff --git a/app/blueprints/__pycache__/admin.cpython-312.pyc b/app/blueprints/__pycache__/admin.cpython-312.pyc index b65da1d..b883687 100644 Binary files a/app/blueprints/__pycache__/admin.cpython-312.pyc and b/app/blueprints/__pycache__/admin.cpython-312.pyc differ diff --git a/app/blueprints/__pycache__/main.cpython-312.pyc b/app/blueprints/__pycache__/main.cpython-312.pyc index 995b8b2..25e36e2 100644 Binary files a/app/blueprints/__pycache__/main.cpython-312.pyc and b/app/blueprints/__pycache__/main.cpython-312.pyc differ diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index ab64fc9..542c53d 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -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}) diff --git a/app/blueprints/main.py b/app/blueprints/main.py index 619f126..b9313fd 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -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) \ No newline at end of file + return Response(f"代理错误: {str(e)}", status=500) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index 41681e7..fd309f9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -15,6 +15,12 @@ public class PermissionMenuSchemaInitializer { private final JdbcTemplate jdbcTemplate; + private static final List 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 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) { + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index e0619cb..b766696 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -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 createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) { + return ApiResponse.success(priceTrackLoopRunService.createLoopRun(request)); + } + + @GetMapping("/loop-runs/{loopRunId}") + @Operation(summary = "查询跟价循环任务", description = "返回循环任务当前状态与进度。") + public ApiResponse 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 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 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 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 deleteTask( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackLoopRunMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackLoopRunMapper.java new file mode 100644 index 0000000..05760a3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackLoopRunMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java index 66d4e04..204fb92 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java @@ -33,4 +33,12 @@ public class PriceTrackCreateTaskRequest { @NotEmpty(message = "国家列表不能为空") @Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]") private List 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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunChildFinishedRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunChildFinishedRequest.java new file mode 100644 index 0000000..66c3c38 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunChildFinishedRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java new file mode 100644 index 0000000..7aa353b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java @@ -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 items = new ArrayList<>(); + + private List asinFiles = new ArrayList<>(); + + @NotEmpty(message = "countryCodes不能为空") + private List countryCodes = new ArrayList<>(); + + @NotBlank(message = "executionMode不能为空") + @Schema(description = "FINITE or INFINITE", example = "FINITE") + private String executionMode; + + @Schema(description = "固定轮次时必填", example = "3") + private Integer targetRounds; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackLoopRunEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackLoopRunEntity.java new file mode 100644 index 0000000..4a55547 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackLoopRunEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskRequestVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskRequestVo.java new file mode 100644 index 0000000..ac7b9de --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskRequestVo.java @@ -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 items = new ArrayList<>(); + private List asinFiles = new ArrayList<>(); + private List countryCodes = new ArrayList<>(); + private Long loopRunId; + private Integer roundIndex; + private Integer shopIndex; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunDispatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunDispatchVo.java new file mode 100644 index 0000000..b7862d6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunDispatchVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunVo.java new file mode 100644 index 0000000..188cd38 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackLoopRunVo.java @@ -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 asinFiles = new ArrayList<>(); + private List countryCodes = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java index 4e91d6e..e97d76f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackTaskItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackTaskItemVo.java index 80c86c8..a9a3446 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackTaskItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackTaskItemVo.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackLoopRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackLoopRunService.java new file mode 100644 index 0000000..5ce71eb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackLoopRunService.java @@ -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 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 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 loops = loopRunMapper.selectList(new LambdaQueryWrapper() + .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 loops = loopRunMapper.selectList(new LambdaQueryWrapper() + .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 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 payload = objectMapper.readValue(task.getRequestJson(), new TypeReference>() {}); + 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 dedupeItems(List rawItems) { + Map 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 parseShops(PriceTrackLoopRunEntity entity) { + if (entity == null || entity.getShopsJson() == null || entity.getShopsJson().isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(entity.getShopsJson(), new TypeReference>() {}); + } catch (Exception ex) { + log.warn("[price-track-loop] parse shops failed loopRunId={} msg={}", entity.getId(), ex.getMessage()); + return List.of(); + } + } + + private List parseStringList(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(rawJson, new TypeReference>() {}); + } catch (Exception ex) { + return List.of(); + } + } + + private ChildTaskContext parseChildTaskContext(FileTaskEntity task) { + if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) { + return null; + } + try { + Map payload = objectMapper.readValue(task.getRequestJson(), new TypeReference>() {}); + 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) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 649d53c..87e4999 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -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 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 payload = objectMapper.readValue(task.getRequestJson(), new TypeReference>() {}); + 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 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 buildSnapshotFromDb(Long taskId, String taskStatus) { List rows = fileResultMapper.selectList( new LambdaQueryWrapper() @@ -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) { diff --git a/backend-java/src/main/resources/db/V26__price_track_loop_runs.sql b/backend-java/src/main/resources/db/V26__price_track_loop_runs.sql new file mode 100644 index 0000000..610b202 --- /dev/null +++ b/backend-java/src/main/resources/db/V26__price_track_loop_runs.sql @@ -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'; diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index 6a22003..20cf188 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -20,6 +20,24 @@ +
执行配置
+
+ + +
+ 轮次 + +
+
+