改造后台权限和APP权限
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -268,6 +268,7 @@ def admin_delete_user(uid):
|
|||||||
def admin_user_column_permissions(uid):
|
def admin_user_column_permissions(uid):
|
||||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||||
current_uid = session.get('user_id')
|
current_uid = session.get('user_id')
|
||||||
|
menu_type = (request.args.get('menu_type') or '').strip().lower()
|
||||||
if current_uid != uid:
|
if current_uid != uid:
|
||||||
role, _ = _get_current_admin_role()
|
role, _ = _get_current_admin_role()
|
||||||
if not role:
|
if not role:
|
||||||
@@ -277,19 +278,32 @@ def admin_user_column_permissions(uid):
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||||
user_row = cur.fetchone()
|
user_row = cur.fetchone()
|
||||||
|
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
|
||||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||||
cur.execute("""
|
sql = """
|
||||||
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
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()
|
rows = cur.fetchall()
|
||||||
else:
|
else:
|
||||||
cur.execute("""
|
sql = """
|
||||||
SELECT c.id, c.name, c.column_key, c.created_at
|
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
|
||||||
FROM columns c
|
FROM columns c
|
||||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||||
WHERE ucp.user_id = %s
|
WHERE ucp.user_id = %s
|
||||||
ORDER BY c.id
|
"""
|
||||||
""", (uid,))
|
params = [uid]
|
||||||
|
if valid_menu_type:
|
||||||
|
sql += " AND c.menu_type = %s"
|
||||||
|
params.append(valid_menu_type)
|
||||||
|
sql += " ORDER BY c.sort_order ASC, c.id ASC"
|
||||||
|
cur.execute(sql, tuple(params))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
items = [
|
items = [
|
||||||
@@ -297,6 +311,9 @@ def admin_user_column_permissions(uid):
|
|||||||
'id': r['id'],
|
'id': r['id'],
|
||||||
'name': r['name'],
|
'name': r['name'],
|
||||||
'column_key': r['column_key'],
|
'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 '',
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def wb():
|
|||||||
@main_bp.route('/brand')
|
@main_bp.route('/brand')
|
||||||
@login_required
|
@login_required
|
||||||
def brand_page():
|
def brand_page():
|
||||||
return _render_html('brand.html')
|
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
|
||||||
|
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
|
||||||
|
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
|
||||||
|
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
|
||||||
|
);
|
||||||
|
|
||||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||||
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
||||||
@@ -44,6 +50,7 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
|
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
|
||||||
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
|
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
|
||||||
ensureDefaultAdminMenus();
|
ensureDefaultAdminMenus();
|
||||||
|
ensureDefaultAppMenus();
|
||||||
executeQuietly("""
|
executeQuietly("""
|
||||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
@@ -72,6 +79,23 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureDefaultAppMenus() {
|
||||||
|
for (DefaultAppMenu menu : DEFAULT_APP_MENUS) {
|
||||||
|
executeQuietly("""
|
||||||
|
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||||
|
SELECT '%s', '%s', 'app', '%s', %d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM columns WHERE column_key = '%s'
|
||||||
|
)
|
||||||
|
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||||
|
executeQuietly("""
|
||||||
|
UPDATE columns
|
||||||
|
SET sort_order = %d, route_path = '%s', menu_type = 'app'
|
||||||
|
WHERE column_key = '%s'
|
||||||
|
""".formatted(menu.sortOrder(), menu.routePath(), menu.columnKey()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void executeQuietly(String sql) {
|
private void executeQuietly(String sql) {
|
||||||
try {
|
try {
|
||||||
jdbcTemplate.execute(sql);
|
jdbcTemplate.execute(sql);
|
||||||
@@ -82,4 +106,7 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
|
|
||||||
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
|
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.nanri.aiimage.common.api.ApiResponse;
|
|||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
|
||||||
@@ -12,9 +14,12 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreference
|
|||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -53,6 +58,7 @@ public class PriceTrackController {
|
|||||||
|
|
||||||
private final PriceTrackService priceTrackService;
|
private final PriceTrackService priceTrackService;
|
||||||
private final PriceTrackTaskService priceTrackTaskService;
|
private final PriceTrackTaskService priceTrackTaskService;
|
||||||
|
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||||
|
|
||||||
@GetMapping("/candidates")
|
@GetMapping("/candidates")
|
||||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
||||||
@@ -131,6 +137,45 @@ public class PriceTrackController {
|
|||||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/loop-runs")
|
||||||
|
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
|
||||||
|
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {
|
||||||
|
return ApiResponse.success(priceTrackLoopRunService.createLoopRun(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/loop-runs/{loopRunId}")
|
||||||
|
@Operation(summary = "查询跟价循环任务", description = "返回循环任务当前状态与进度。")
|
||||||
|
public ApiResponse<PriceTrackLoopRunVo> getLoopRun(
|
||||||
|
@PathVariable Long loopRunId,
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
return ApiResponse.success(priceTrackLoopRunService.getLoopRun(loopRunId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/loop-runs/{loopRunId}/dispatch-next")
|
||||||
|
@Operation(summary = "获取循环任务下一条派发信息", description = "当前无活跃子任务时调用,返回下一条应该创建并入队的子任务请求。")
|
||||||
|
public ApiResponse<PriceTrackLoopRunDispatchVo> dispatchNext(
|
||||||
|
@PathVariable Long loopRunId,
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
return ApiResponse.success(priceTrackLoopRunService.dispatchNext(loopRunId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/loop-runs/{loopRunId}/child-finished")
|
||||||
|
@Operation(summary = "通知循环任务子任务已结束", description = "子任务进入终态后调用,用于推进到下一店或下一轮。")
|
||||||
|
public ApiResponse<PriceTrackLoopRunVo> childFinished(
|
||||||
|
@PathVariable Long loopRunId,
|
||||||
|
@RequestParam("user_id") Long userId,
|
||||||
|
@Valid @RequestBody PriceTrackLoopRunChildFinishedRequest request) {
|
||||||
|
return ApiResponse.success(priceTrackLoopRunService.childFinished(loopRunId, userId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/loop-runs/{loopRunId}/stop")
|
||||||
|
@Operation(summary = "停止循环任务", description = "请求停止循环;若无活跃子任务则立即停止,否则等待当前子任务完成后收尾。")
|
||||||
|
public ApiResponse<PriceTrackLoopRunVo> stopLoopRun(
|
||||||
|
@PathVariable Long loopRunId,
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
return ApiResponse.success(priceTrackLoopRunService.requestStop(loopRunId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/tasks/{taskId}")
|
@DeleteMapping("/tasks/{taskId}")
|
||||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行,RUNNING、SUCCESS、FAILED 均可删除。")
|
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行,RUNNING、SUCCESS、FAILED 均可删除。")
|
||||||
public ApiResponse<Void> deleteTask(
|
public ApiResponse<Void> deleteTask(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PriceTrackLoopRunMapper extends BaseMapper<PriceTrackLoopRunEntity> {
|
||||||
|
}
|
||||||
@@ -33,4 +33,12 @@ public class PriceTrackCreateTaskRequest {
|
|||||||
@NotEmpty(message = "国家列表不能为空")
|
@NotEmpty(message = "国家列表不能为空")
|
||||||
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||||
private List<String> countryCodes;
|
private List<String> countryCodes;
|
||||||
|
@Schema(description = "optional loop run id", example = "1")
|
||||||
|
private Long loopRunId;
|
||||||
|
|
||||||
|
@Schema(description = "optional round index, 1-based", example = "2")
|
||||||
|
private Integer roundIndex;
|
||||||
|
|
||||||
|
@Schema(description = "optional shop index, 0-based", example = "0")
|
||||||
|
private Integer shopIndex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "循环任务子任务完成通知")
|
||||||
|
public class PriceTrackLoopRunChildFinishedRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "childTaskId不能为空")
|
||||||
|
private Long childTaskId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "创建跟价循环任务请求")
|
||||||
|
public class PriceTrackLoopRunCreateRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "user_id不能为空")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private boolean statusMode;
|
||||||
|
|
||||||
|
private boolean asinMode;
|
||||||
|
|
||||||
|
@NotEmpty(message = "items不能为空")
|
||||||
|
@Valid
|
||||||
|
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<String> asinFiles = new ArrayList<>();
|
||||||
|
|
||||||
|
@NotEmpty(message = "countryCodes不能为空")
|
||||||
|
private List<String> countryCodes = new ArrayList<>();
|
||||||
|
|
||||||
|
@NotBlank(message = "executionMode不能为空")
|
||||||
|
@Schema(description = "FINITE or INFINITE", example = "FINITE")
|
||||||
|
private String executionMode;
|
||||||
|
|
||||||
|
@Schema(description = "固定轮次时必填", example = "3")
|
||||||
|
private Integer targetRounds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("biz_price_track_loop_run")
|
||||||
|
public class PriceTrackLoopRunEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private Long userId;
|
||||||
|
private String status;
|
||||||
|
private String executionMode;
|
||||||
|
private Integer targetRounds;
|
||||||
|
private Integer currentRound;
|
||||||
|
private Integer currentShopIndex;
|
||||||
|
private Boolean statusMode;
|
||||||
|
private Boolean asinMode;
|
||||||
|
private String asinFilesJson;
|
||||||
|
private String countryCodesJson;
|
||||||
|
private String shopsJson;
|
||||||
|
private Long activeTaskId;
|
||||||
|
private Boolean stopRequested;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private LocalDateTime finishedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PriceTrackCreateTaskRequestVo {
|
||||||
|
private Long userId;
|
||||||
|
private boolean statusMode;
|
||||||
|
private boolean asinMode;
|
||||||
|
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||||
|
private List<String> asinFiles = new ArrayList<>();
|
||||||
|
private List<String> countryCodes = new ArrayList<>();
|
||||||
|
private Long loopRunId;
|
||||||
|
private Integer roundIndex;
|
||||||
|
private Integer shopIndex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "循环任务下一条派发信息")
|
||||||
|
public class PriceTrackLoopRunDispatchVo {
|
||||||
|
|
||||||
|
private PriceTrackLoopRunVo loopRun;
|
||||||
|
|
||||||
|
private PriceTrackCreateTaskRequestVo childTaskRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "跟价循环任务")
|
||||||
|
public class PriceTrackLoopRunVo {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String status;
|
||||||
|
private String executionMode;
|
||||||
|
private Integer targetRounds;
|
||||||
|
private Integer currentRound;
|
||||||
|
private Integer currentShopIndex;
|
||||||
|
private Integer totalShopCount;
|
||||||
|
private Long activeTaskId;
|
||||||
|
private String activeTaskStatus;
|
||||||
|
private Boolean stopRequested;
|
||||||
|
private String errorMessage;
|
||||||
|
private Boolean statusMode;
|
||||||
|
private Boolean asinMode;
|
||||||
|
private List<String> asinFiles = new ArrayList<>();
|
||||||
|
private List<String> countryCodes = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -49,4 +49,10 @@ public class PriceTrackResultItemVo {
|
|||||||
|
|
||||||
@Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取")
|
@Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取")
|
||||||
private String downloadUrl;
|
private String downloadUrl;
|
||||||
|
|
||||||
|
@Schema(description = "所属循环任务 ID", example = "1")
|
||||||
|
private Long loopRunId;
|
||||||
|
|
||||||
|
@Schema(description = "所属轮次,1-based", example = "2")
|
||||||
|
private Integer roundIndex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,10 @@ public class PriceTrackTaskItemVo {
|
|||||||
|
|
||||||
@Schema(description = "完成时间;未结束时为空")
|
@Schema(description = "完成时间;未结束时为空")
|
||||||
private String finishedAt;
|
private String finishedAt;
|
||||||
|
|
||||||
|
@Schema(description = "所属循环任务 ID", example = "1")
|
||||||
|
private Long loopRunId;
|
||||||
|
|
||||||
|
@Schema(description = "所属轮次,1-based", example = "2")
|
||||||
|
private Integer roundIndex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackLoopRunMapper;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskRequestVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PriceTrackLoopRunService {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "PRICE_TRACK";
|
||||||
|
private static final String STATUS_RUNNING = "RUNNING";
|
||||||
|
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||||
|
private static final String STATUS_FAILED = "FAILED";
|
||||||
|
private static final String STATUS_STOPPED = "STOPPED";
|
||||||
|
private static final String EXECUTION_MODE_FINITE = "FINITE";
|
||||||
|
private static final String EXECUTION_MODE_INFINITE = "INFINITE";
|
||||||
|
|
||||||
|
private final PriceTrackLoopRunMapper loopRunMapper;
|
||||||
|
private final FileTaskMapper fileTaskMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceTrackLoopRunVo createLoopRun(PriceTrackLoopRunCreateRequest request) {
|
||||||
|
validateCreateRequest(request);
|
||||||
|
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = dedupeItems(request.getItems());
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
throw new BusinessException("items不能为空");
|
||||||
|
}
|
||||||
|
PriceTrackLoopRunEntity entity = new PriceTrackLoopRunEntity();
|
||||||
|
entity.setUserId(request.getUserId());
|
||||||
|
entity.setStatus(STATUS_RUNNING);
|
||||||
|
entity.setExecutionMode(normalizeExecutionMode(request.getExecutionMode()));
|
||||||
|
entity.setTargetRounds("FINITE".equals(normalizeExecutionMode(request.getExecutionMode())) ? request.getTargetRounds() : null);
|
||||||
|
entity.setCurrentRound(1);
|
||||||
|
entity.setCurrentShopIndex(0);
|
||||||
|
entity.setStatusMode(request.isStatusMode());
|
||||||
|
entity.setAsinMode(request.isAsinMode());
|
||||||
|
entity.setActiveTaskId(null);
|
||||||
|
entity.setStopRequested(false);
|
||||||
|
entity.setErrorMessage(null);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
try {
|
||||||
|
entity.setAsinFilesJson(objectMapper.writeValueAsString(request.getAsinFiles() == null ? List.of() : request.getAsinFiles()));
|
||||||
|
entity.setCountryCodesJson(objectMapper.writeValueAsString(request.getCountryCodes()));
|
||||||
|
entity.setShopsJson(objectMapper.writeValueAsString(items));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("循环任务序列化失败");
|
||||||
|
}
|
||||||
|
loopRunMapper.insert(entity);
|
||||||
|
return toVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PriceTrackLoopRunVo getLoopRun(Long loopRunId, Long userId) {
|
||||||
|
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||||
|
reconcileWithTerminalChild(entity);
|
||||||
|
return toVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceTrackLoopRunDispatchVo dispatchNext(Long loopRunId, Long userId) {
|
||||||
|
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||||
|
reconcileWithTerminalChild(entity);
|
||||||
|
if (isTerminal(entity.getStatus())) {
|
||||||
|
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||||
|
vo.setLoopRun(toVo(entity));
|
||||||
|
vo.setChildTaskRequest(null);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(entity.getStopRequested()) && entity.getActiveTaskId() == null) {
|
||||||
|
markStopped(entity, null);
|
||||||
|
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||||
|
vo.setLoopRun(toVo(entity));
|
||||||
|
vo.setChildTaskRequest(null);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
if (entity.getActiveTaskId() != null) {
|
||||||
|
throw new BusinessException("当前仍有执行中的子任务");
|
||||||
|
}
|
||||||
|
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
throw new BusinessException("循环任务缺少店铺配置");
|
||||||
|
}
|
||||||
|
int shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
|
||||||
|
if (shopIndex >= items.size()) {
|
||||||
|
advanceAfterSuccessfulRound(entity, items.size());
|
||||||
|
if (isTerminal(entity.getStatus())) {
|
||||||
|
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||||
|
vo.setLoopRun(toVo(entity));
|
||||||
|
vo.setChildTaskRequest(null);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
|
||||||
|
}
|
||||||
|
PriceTrackCreateTaskRequestVo childRequest = new PriceTrackCreateTaskRequestVo();
|
||||||
|
childRequest.setUserId(entity.getUserId());
|
||||||
|
childRequest.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
|
||||||
|
childRequest.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
|
||||||
|
childRequest.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
|
||||||
|
childRequest.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
|
||||||
|
childRequest.setItems(List.of(items.get(shopIndex)));
|
||||||
|
childRequest.setLoopRunId(entity.getId());
|
||||||
|
childRequest.setRoundIndex(Math.max(1, entity.getCurrentRound() == null ? 1 : entity.getCurrentRound()));
|
||||||
|
childRequest.setShopIndex(shopIndex);
|
||||||
|
|
||||||
|
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||||
|
vo.setLoopRun(toVo(entity));
|
||||||
|
vo.setChildTaskRequest(childRequest);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceTrackLoopRunVo childFinished(Long loopRunId, Long userId, PriceTrackLoopRunChildFinishedRequest request) {
|
||||||
|
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||||
|
handleChildFinished(entity, request.getChildTaskId(), true);
|
||||||
|
return toVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceTrackLoopRunVo requestStop(Long loopRunId, Long userId) {
|
||||||
|
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||||
|
entity.setStopRequested(true);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (entity.getActiveTaskId() == null || isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||||
|
markStopped(entity, null);
|
||||||
|
} else {
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
return toVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void bindChildTask(Long loopRunId, Long childTaskId, Integer roundIndex, Integer shopIndex) {
|
||||||
|
if (loopRunId == null || childTaskId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
|
||||||
|
if (entity == null || isTerminal(entity.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity.getCurrentRound() == null || entity.getCurrentShopIndex() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entity.getCurrentRound().equals(roundIndex) || !entity.getCurrentShopIndex().equals(shopIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity.getActiveTaskId() != null && !isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entity.setActiveTaskId(childTaskId);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void syncLoopRunAfterChildTerminal(Long childTaskId) {
|
||||||
|
if (childTaskId == null || childTaskId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
|
||||||
|
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
|
||||||
|
.last("limit 5"));
|
||||||
|
for (PriceTrackLoopRunEntity entity : loops) {
|
||||||
|
handleChildFinished(entity, childTaskId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void syncLoopRunAfterChildRemoved(Long childTaskId) {
|
||||||
|
if (childTaskId == null || childTaskId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
|
||||||
|
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
|
||||||
|
.last("limit 5"));
|
||||||
|
for (PriceTrackLoopRunEntity entity : loops) {
|
||||||
|
if (entity == null || isTerminal(entity.getStatus())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entity.setActiveTaskId(null);
|
||||||
|
entity.setStatus(STATUS_STOPPED);
|
||||||
|
entity.setStopRequested(true);
|
||||||
|
entity.setErrorMessage("当前循环子任务已被删除,循环已停止");
|
||||||
|
entity.setFinishedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleChildFinished(PriceTrackLoopRunEntity entity, Long childTaskId, boolean validateOwnership) {
|
||||||
|
if (entity == null || childTaskId == null || childTaskId <= 0 || isTerminal(entity.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(childTaskId);
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
throw new BusinessException("子任务不存在");
|
||||||
|
}
|
||||||
|
if (validateOwnership && !entity.getUserId().equals(task.getUserId())) {
|
||||||
|
throw new BusinessException("子任务不属于当前用户");
|
||||||
|
}
|
||||||
|
if (!isTerminal(task.getStatus())) {
|
||||||
|
throw new BusinessException("子任务尚未结束");
|
||||||
|
}
|
||||||
|
if (!belongsToLoop(task, entity.getId())) {
|
||||||
|
throw new BusinessException("子任务不属于当前循环");
|
||||||
|
}
|
||||||
|
if (entity.getActiveTaskId() != null && !entity.getActiveTaskId().equals(childTaskId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity.getActiveTaskId() == null) {
|
||||||
|
ChildTaskContext ctx = parseChildTaskContext(task);
|
||||||
|
if (ctx == null
|
||||||
|
|| !ctx.roundIndex().equals(entity.getCurrentRound())
|
||||||
|
|| !ctx.shopIndex().equals(entity.getCurrentShopIndex())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setActiveTaskId(null);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (STATUS_FAILED.equals(task.getStatus())) {
|
||||||
|
entity.setStatus(STATUS_FAILED);
|
||||||
|
entity.setErrorMessage(task.getErrorMessage() == null || task.getErrorMessage().isBlank()
|
||||||
|
? "子任务执行失败"
|
||||||
|
: task.getErrorMessage());
|
||||||
|
entity.setFinishedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
entity.setStatus(STATUS_FAILED);
|
||||||
|
entity.setErrorMessage("循环任务缺少店铺配置");
|
||||||
|
entity.setFinishedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(entity.getStopRequested())) {
|
||||||
|
markStopped(entity, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int nextShopIndex = (entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex()) + 1;
|
||||||
|
if (nextShopIndex < items.size()) {
|
||||||
|
entity.setCurrentShopIndex(nextShopIndex);
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advanceAfterSuccessfulRound(entity, items.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcileWithTerminalChild(PriceTrackLoopRunEntity entity) {
|
||||||
|
if (entity == null || entity.getActiveTaskId() == null || isTerminal(entity.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||||
|
handleChildFinished(entity, entity.getActiveTaskId(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void advanceAfterSuccessfulRound(PriceTrackLoopRunEntity entity, int shopCount) {
|
||||||
|
if (Boolean.TRUE.equals(entity.getStopRequested())) {
|
||||||
|
markStopped(entity, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (EXECUTION_MODE_FINITE.equals(entity.getExecutionMode())) {
|
||||||
|
int targetRounds = entity.getTargetRounds() == null ? 1 : Math.max(1, entity.getTargetRounds());
|
||||||
|
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
|
||||||
|
if (currentRound >= targetRounds) {
|
||||||
|
entity.setStatus(STATUS_SUCCESS);
|
||||||
|
entity.setFinishedAt(LocalDateTime.now());
|
||||||
|
} else {
|
||||||
|
entity.setCurrentRound(currentRound + 1);
|
||||||
|
entity.setCurrentShopIndex(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
|
||||||
|
entity.setCurrentRound(currentRound + 1);
|
||||||
|
entity.setCurrentShopIndex(0);
|
||||||
|
}
|
||||||
|
entity.setActiveTaskId(null);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markStopped(PriceTrackLoopRunEntity entity, String message) {
|
||||||
|
entity.setStatus(STATUS_STOPPED);
|
||||||
|
entity.setActiveTaskId(null);
|
||||||
|
entity.setErrorMessage(message);
|
||||||
|
entity.setFinishedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
loopRunMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isChildTaskTerminal(Long taskId) {
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
return task != null && isTerminal(task.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean belongsToLoop(FileTaskEntity task, Long loopRunId) {
|
||||||
|
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||||
|
Object raw = payload.get("loopRunId");
|
||||||
|
if (raw instanceof Number number) {
|
||||||
|
return loopRunId.equals(number.longValue());
|
||||||
|
}
|
||||||
|
if (raw instanceof String text && !text.isBlank()) {
|
||||||
|
return loopRunId.equals(Long.parseLong(text));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[price-track-loop] parse child loopRunId failed taskId={} msg={}", task.getId(), ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceTrackLoopRunEntity getOwnedLoopRun(Long loopRunId, Long userId) {
|
||||||
|
if (loopRunId == null || loopRunId <= 0) {
|
||||||
|
throw new BusinessException("loopRunId不合法");
|
||||||
|
}
|
||||||
|
if (userId == null || userId <= 0) {
|
||||||
|
throw new BusinessException("user_id不合法");
|
||||||
|
}
|
||||||
|
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
|
||||||
|
if (entity == null || !userId.equals(entity.getUserId())) {
|
||||||
|
throw new BusinessException("循环任务不存在");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCreateRequest(PriceTrackLoopRunCreateRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new BusinessException("请求不能为空");
|
||||||
|
}
|
||||||
|
if (request.isStatusMode() == request.isAsinMode()) {
|
||||||
|
throw new BusinessException("跟价模式必须二选一");
|
||||||
|
}
|
||||||
|
if (request.isAsinMode() && (request.getAsinFiles() == null || request.getAsinFiles().isEmpty())) {
|
||||||
|
throw new BusinessException("自定义ASIN模式必须上传ASIN文件");
|
||||||
|
}
|
||||||
|
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||||
|
throw new BusinessException("user_id不合法");
|
||||||
|
}
|
||||||
|
String executionMode = normalizeExecutionMode(request.getExecutionMode());
|
||||||
|
if (EXECUTION_MODE_FINITE.equals(executionMode) && (request.getTargetRounds() == null || request.getTargetRounds() <= 0)) {
|
||||||
|
throw new BusinessException("固定轮次必须大于0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeExecutionMode(String raw) {
|
||||||
|
String value = raw == null ? "" : raw.trim().toUpperCase();
|
||||||
|
if (EXECUTION_MODE_FINITE.equals(value) || EXECUTION_MODE_INFINITE.equals(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new BusinessException("executionMode不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> rawItems) {
|
||||||
|
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> out = new LinkedHashMap<>();
|
||||||
|
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : rawItems) {
|
||||||
|
if (item == null || !Boolean.TRUE.equals(item.getMatched())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||||
|
if (!key.isBlank()) {
|
||||||
|
out.putIfAbsent(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(out.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceTrackLoopRunVo toVo(PriceTrackLoopRunEntity entity) {
|
||||||
|
PriceTrackLoopRunVo vo = new PriceTrackLoopRunVo();
|
||||||
|
vo.setId(entity.getId());
|
||||||
|
vo.setStatus(entity.getStatus());
|
||||||
|
vo.setExecutionMode(entity.getExecutionMode());
|
||||||
|
vo.setTargetRounds(entity.getTargetRounds());
|
||||||
|
vo.setCurrentRound(entity.getCurrentRound());
|
||||||
|
vo.setCurrentShopIndex(entity.getCurrentShopIndex());
|
||||||
|
vo.setTotalShopCount(parseShops(entity).size());
|
||||||
|
vo.setActiveTaskId(entity.getActiveTaskId());
|
||||||
|
vo.setStopRequested(Boolean.TRUE.equals(entity.getStopRequested()));
|
||||||
|
vo.setErrorMessage(entity.getErrorMessage());
|
||||||
|
vo.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
|
||||||
|
vo.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
|
||||||
|
vo.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
|
||||||
|
vo.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
|
||||||
|
if (entity.getActiveTaskId() != null) {
|
||||||
|
FileTaskEntity activeTask = fileTaskMapper.selectById(entity.getActiveTaskId());
|
||||||
|
if (activeTask != null) {
|
||||||
|
vo.setActiveTaskStatus(activeTask.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> parseShops(PriceTrackLoopRunEntity entity) {
|
||||||
|
if (entity == null || entity.getShopsJson() == null || entity.getShopsJson().isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(entity.getShopsJson(), new TypeReference<List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem>>() {});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[price-track-loop] parse shops failed loopRunId={} msg={}", entity.getId(), ex.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> parseStringList(String rawJson) {
|
||||||
|
if (rawJson == null || rawJson.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(rawJson, new TypeReference<List<String>>() {});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChildTaskContext parseChildTaskContext(FileTaskEntity task) {
|
||||||
|
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||||
|
Integer roundIndex = toInteger(payload.get("roundIndex"));
|
||||||
|
Integer shopIndex = toInteger(payload.get("shopIndex"));
|
||||||
|
if (roundIndex == null || shopIndex == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ChildTaskContext(roundIndex, shopIndex);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTerminal(String status) {
|
||||||
|
return STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status) || STATUS_STOPPED.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer toInteger(Object raw) {
|
||||||
|
if (raw instanceof Number number) {
|
||||||
|
return number.intValue();
|
||||||
|
}
|
||||||
|
if (raw instanceof String text && !text.isBlank()) {
|
||||||
|
return Integer.parseInt(text);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ChildTaskContext(Integer roundIndex, Integer shopIndex) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ public class PriceTrackTaskService {
|
|||||||
private final SkipPriceAsinService skipPriceAsinService;
|
private final SkipPriceAsinService skipPriceAsinService;
|
||||||
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
||||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||||
|
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||||
|
|
||||||
public PriceTrackDashboardVo dashboard(Long userId) {
|
public PriceTrackDashboardVo dashboard(Long userId) {
|
||||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||||
@@ -142,6 +143,7 @@ public class PriceTrackTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||||
fileTaskMapper.deleteById(taskId);
|
fileTaskMapper.deleteById(taskId);
|
||||||
priceTrackTaskCacheService.deleteTaskCache(taskId);
|
priceTrackTaskCacheService.deleteTaskCache(taskId);
|
||||||
|
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -253,6 +255,9 @@ public class PriceTrackTaskService {
|
|||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.insert(task);
|
fileTaskMapper.insert(task);
|
||||||
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||||
|
if (request.getLoopRunId() != null) {
|
||||||
|
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
|
||||||
|
}
|
||||||
|
|
||||||
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
|
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
|
||||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
|
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
|
||||||
@@ -267,7 +272,10 @@ public class PriceTrackTaskService {
|
|||||||
fr.setSuccess(0);
|
fr.setSuccess(0);
|
||||||
fr.setCreatedAt(LocalDateTime.now());
|
fr.setCreatedAt(LocalDateTime.now());
|
||||||
fileResultMapper.insert(fr);
|
fileResultMapper.insert(fr);
|
||||||
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
|
PriceTrackResultItemVo snapshotItem = toSnapshotVo(fr, item, task.getStatus());
|
||||||
|
snapshotItem.setLoopRunId(request.getLoopRunId());
|
||||||
|
snapshotItem.setRoundIndex(request.getRoundIndex());
|
||||||
|
snapshot.add(snapshotItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
|
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
|
||||||
@@ -280,6 +288,9 @@ public class PriceTrackTaskService {
|
|||||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||||
ctx.put("items", uniqueItems);
|
ctx.put("items", uniqueItems);
|
||||||
|
ctx.put("loopRunId", request.getLoopRunId());
|
||||||
|
ctx.put("roundIndex", request.getRoundIndex());
|
||||||
|
ctx.put("shopIndex", request.getShopIndex());
|
||||||
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
||||||
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -441,6 +452,7 @@ public class PriceTrackTaskService {
|
|||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.orderByAsc(FileResultEntity::getId));
|
.orderByAsc(FileResultEntity::getId));
|
||||||
if (latest.isEmpty()) {
|
if (latest.isEmpty()) {
|
||||||
|
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
|
||||||
fileTaskMapper.deleteById(taskId);
|
fileTaskMapper.deleteById(taskId);
|
||||||
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||||
return;
|
return;
|
||||||
@@ -768,6 +780,7 @@ public class PriceTrackTaskService {
|
|||||||
vo.setCreatedAt(fmt(task.getCreatedAt()));
|
vo.setCreatedAt(fmt(task.getCreatedAt()));
|
||||||
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
|
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
|
||||||
vo.setFinishedAt(fmt(task.getFinishedAt()));
|
vo.setFinishedAt(fmt(task.getFinishedAt()));
|
||||||
|
mergeTaskLoopFields(vo, task);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,10 +807,34 @@ public class PriceTrackTaskService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mergeLoopFields(payload, vo);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void mergeTaskLoopFields(PriceTrackTaskItemVo vo, FileTaskEntity task) {
|
||||||
|
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||||
|
Object rawLoopRunId = payload.get("loopRunId");
|
||||||
|
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
|
||||||
|
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
|
||||||
|
Object rawRoundIndex = payload.get("roundIndex");
|
||||||
|
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
|
||||||
|
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeLoopFields(Map<String, Object> payload, PriceTrackResultItemVo vo) {
|
||||||
|
Object rawLoopRunId = payload.get("loopRunId");
|
||||||
|
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
|
||||||
|
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
|
||||||
|
Object rawRoundIndex = payload.get("roundIndex");
|
||||||
|
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
|
||||||
|
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
|
||||||
|
}
|
||||||
|
|
||||||
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
||||||
List<FileResultEntity> rows = fileResultMapper.selectList(
|
List<FileResultEntity> rows = fileResultMapper.selectList(
|
||||||
new LambdaQueryWrapper<FileResultEntity>()
|
new LambdaQueryWrapper<FileResultEntity>()
|
||||||
@@ -867,6 +904,9 @@ public class PriceTrackTaskService {
|
|||||||
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
|
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
|
||||||
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
|
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
|
||||||
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
|
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
|
||||||
|
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||||
|
priceTrackLoopRunService.syncLoopRunAfterChildTerminal(task.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS biz_price_track_loop_run (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
|
||||||
|
user_id BIGINT NOT NULL COMMENT 'user id',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/STOPPED',
|
||||||
|
execution_mode VARCHAR(16) NOT NULL COMMENT 'FINITE/INFINITE',
|
||||||
|
target_rounds INT NULL COMMENT 'configured rounds for finite mode',
|
||||||
|
current_round INT NOT NULL DEFAULT 1 COMMENT '1-based current round',
|
||||||
|
current_shop_index INT NOT NULL DEFAULT 0 COMMENT '0-based current shop index',
|
||||||
|
status_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'status mode enabled',
|
||||||
|
asin_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'asin mode enabled',
|
||||||
|
asin_files_json MEDIUMTEXT NULL COMMENT 'asin file paths json',
|
||||||
|
country_codes_json MEDIUMTEXT NOT NULL COMMENT 'country codes json',
|
||||||
|
shops_json MEDIUMTEXT NOT NULL COMMENT 'shop queue items json',
|
||||||
|
active_task_id BIGINT NULL COMMENT 'currently running child task id',
|
||||||
|
stop_requested TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'manual stop requested',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT 'terminal error message',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time',
|
||||||
|
finished_at DATETIME NULL COMMENT 'finished time',
|
||||||
|
KEY idx_user_status (user_id, status),
|
||||||
|
KEY idx_active_task_id (active_task_id)
|
||||||
|
) COMMENT='price track loop runs';
|
||||||
@@ -20,6 +20,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 按指定ASIN文档:上传 -->
|
<!-- 按指定ASIN文档:上传 -->
|
||||||
|
<div class="section-title">执行配置</div>
|
||||||
|
<div class="mode-zone">
|
||||||
|
<label class="mode-check-row">
|
||||||
|
<input v-model="executionMode" type="radio" class="mode-check-input" value="FINITE" />
|
||||||
|
<span class="mode-check-text">执行固定轮次</span>
|
||||||
|
<span class="mode-hint-inline">按整批店铺重复执行指定次数</span>
|
||||||
|
</label>
|
||||||
|
<label class="mode-check-row">
|
||||||
|
<input v-model="executionMode" type="radio" class="mode-check-input" value="INFINITE" />
|
||||||
|
<span class="mode-check-text">无限执行</span>
|
||||||
|
<span class="mode-hint-inline">上一轮全部成功后立即开始下一轮</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="executionMode === 'FINITE'" class="loop-round-input">
|
||||||
|
<span class="hint">轮次</span>
|
||||||
|
<el-input-number v-model="roundCount" :min="1" :max="9999" :disabled="hasRunningLoop || pushing" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="asinModeEnabled">
|
<template v-if="asinModeEnabled">
|
||||||
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
|
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
|
||||||
<div class="upload-zone">
|
<div class="upload-zone">
|
||||||
@@ -101,12 +119,23 @@
|
|||||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonQueue">
|
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||||
|
|
||||||
|
<div v-if="activeLoopRun" class="loop-status-card">
|
||||||
|
<div class="loop-status-line">循环任务 ID:{{ activeLoopRun.id }}</div>
|
||||||
|
<div class="loop-status-line">状态:{{ activeLoopRun.status || '-' }}</div>
|
||||||
|
<div class="loop-status-line">进度:{{ loopProgressText }}</div>
|
||||||
|
<div class="loop-status-line">当前店铺:{{ ((activeLoopRun.currentShopIndex || 0) + 1) }}/{{ activeLoopRun.totalShopCount || 0 }}</div>
|
||||||
|
<div v-if="activeLoopRun.errorMessage" class="loop-status-line loop-error">{{ activeLoopRun.errorMessage }}</div>
|
||||||
|
<button type="button" class="opt-btn loop-stop-btn" :disabled="!hasRunningLoop || stopLoopPending" @click="stopLoopExecution">
|
||||||
|
{{ stopLoopPending ? '停止中...' : '停止循环' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="queuePushResult" class="queue-debug-card">
|
<div v-if="queuePushResult" class="queue-debug-card">
|
||||||
<div class="section-title queue-debug-title">推送结果</div>
|
<div class="section-title queue-debug-title">推送结果</div>
|
||||||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||||||
@@ -176,6 +205,7 @@
|
|||||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
||||||
class="task-item split-result-item">
|
class="task-item split-result-item">
|
||||||
<div class="left split-result-main">
|
<div class="left split-result-main">
|
||||||
|
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||||
@@ -198,6 +228,7 @@
|
|||||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
||||||
class="task-item split-result-item">
|
class="task-item split-result-item">
|
||||||
<div class="left split-result-main">
|
<div class="left split-result-main">
|
||||||
|
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
||||||
@@ -230,7 +261,10 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
|||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import {
|
import {
|
||||||
addPriceTrackCandidate,
|
addPriceTrackCandidate,
|
||||||
|
completePriceTrackLoopChild,
|
||||||
createPriceTrackTask,
|
createPriceTrackTask,
|
||||||
|
createPriceTrackLoopRun,
|
||||||
|
dispatchNextPriceTrackLoopRun,
|
||||||
deletePendingPriceTrackShopResult,
|
deletePendingPriceTrackShopResult,
|
||||||
deletePriceTrackCandidate,
|
deletePriceTrackCandidate,
|
||||||
deletePriceTrackHistory,
|
deletePriceTrackHistory,
|
||||||
@@ -238,17 +272,21 @@ import {
|
|||||||
getPriceTrackCountryPreference,
|
getPriceTrackCountryPreference,
|
||||||
getPriceTrackDashboard,
|
getPriceTrackDashboard,
|
||||||
getPriceTrackHistory,
|
getPriceTrackHistory,
|
||||||
|
getPriceTrackLoopRun,
|
||||||
getPriceTrackResultDownloadUrl,
|
getPriceTrackResultDownloadUrl,
|
||||||
getPriceTrackTaskProgressBatch,
|
getPriceTrackTaskProgressBatch,
|
||||||
getPriceTrackTasksBatch,
|
getPriceTrackTasksBatch,
|
||||||
listPriceTrackCandidates,
|
listPriceTrackCandidates,
|
||||||
matchPriceTrackShops,
|
matchPriceTrackShops,
|
||||||
putPriceTrackCountryPreference,
|
putPriceTrackCountryPreference,
|
||||||
|
stopPriceTrackLoopRun,
|
||||||
type PriceTrackAsinParsedRow,
|
type PriceTrackAsinParsedRow,
|
||||||
type PriceTrackCandidateVo,
|
type PriceTrackCandidateVo,
|
||||||
type PriceTrackCreateTaskVo,
|
type PriceTrackCreateTaskVo,
|
||||||
type PriceTrackDashboardVo,
|
type PriceTrackDashboardVo,
|
||||||
|
type PriceTrackExecutionMode,
|
||||||
type PriceTrackHistoryItem,
|
type PriceTrackHistoryItem,
|
||||||
|
type PriceTrackLoopRunVo,
|
||||||
type PriceTrackShopQueueItem,
|
type PriceTrackShopQueueItem,
|
||||||
type PriceTrackTaskDetailVo,
|
type PriceTrackTaskDetailVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
@@ -273,9 +311,15 @@ const pushing = ref(false)
|
|||||||
const queuePushResult = ref('')
|
const queuePushResult = ref('')
|
||||||
const queuePayloadText = ref('')
|
const queuePayloadText = ref('')
|
||||||
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||||
|
const executionMode = ref<PriceTrackExecutionMode>('FINITE')
|
||||||
|
const roundCount = ref(1)
|
||||||
|
const activeLoopRunId = ref<number | null>(null)
|
||||||
|
const activeLoopRun = ref<PriceTrackLoopRunVo | null>(null)
|
||||||
|
const loopDispatching = ref(false)
|
||||||
|
const stopLoopPending = ref(false)
|
||||||
|
|
||||||
// 跟价模式(可同时启用)
|
// 跟价模式(可同时启用)
|
||||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
const statusModeEnabled = ref(true) // 按商品状态(全量)
|
||||||
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
|
|||||||
|
|
||||||
// 是否有有效模式
|
// 是否有有效模式
|
||||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||||
|
const loopIsTerminal = computed(() => {
|
||||||
|
const status = activeLoopRun.value?.status || ''
|
||||||
|
return status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED'
|
||||||
|
})
|
||||||
|
const hasRunningLoop = computed(() => !!activeLoopRunId.value && !loopIsTerminal.value)
|
||||||
|
const loopProgressText = computed(() => {
|
||||||
|
const loop = activeLoopRun.value
|
||||||
|
if (!loop) return ''
|
||||||
|
const round = loop.currentRound || 1
|
||||||
|
if (loop.executionMode === 'INFINITE') return `第 ${round} 轮(无限)`
|
||||||
|
const total = Math.max(1, loop.targetRounds || roundCount.value || 1)
|
||||||
|
return `第 ${round}/${total} 轮`
|
||||||
|
})
|
||||||
|
|
||||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||||
const dragCountryIndex = ref<number | null>(null)
|
const dragCountryIndex = ref<number | null>(null)
|
||||||
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
|
|||||||
return `price-track:matched-items:${uidForStorage()}`
|
return `price-track:matched-items:${uidForStorage()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeLoopStorageKey() {
|
||||||
|
return `price-track:active-loop:${uidForStorage()}`
|
||||||
|
}
|
||||||
|
|
||||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
try {
|
try {
|
||||||
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
|
|||||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadActiveLoopRunIdFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(activeLoopStorageKey()) : null
|
||||||
|
if (!raw) {
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed = Number(JSON.parse(raw))
|
||||||
|
activeLoopRunId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||||
|
} catch {
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveActiveLoopRunId() {
|
||||||
|
setStorageJson(activeLoopStorageKey(), activeLoopRunId.value, !activeLoopRunId.value)
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 文件上传 ==========
|
// ========== 文件上传 ==========
|
||||||
async function selectAsinFile() {
|
async function selectAsinFile() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
@@ -704,15 +783,18 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
|||||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||||
if (!rows.length) return
|
if (!rows.length) return
|
||||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
const names = new Set(rows.map((row) => (row.shopName || '').trim()).filter((name) => !!name))
|
||||||
|
matchedItems.value = matchedItems.value.filter((row) => {
|
||||||
|
const key = rowKeyForMatch(row)
|
||||||
|
const name = (row.shopName || '').trim()
|
||||||
|
return !keys.has(key) && (!name || !names.has(name))
|
||||||
|
})
|
||||||
saveMatchedItemsToStorage()
|
saveMatchedItemsToStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||||
const key = rowKeyForMatch(row)
|
|
||||||
const backup = [...matchedItems.value]
|
const backup = [...matchedItems.value]
|
||||||
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
|
removeMatchedRowsLocally([row])
|
||||||
saveMatchedItemsToStorage()
|
|
||||||
const name = (row.shopName || '').trim()
|
const name = (row.shopName || '').trim()
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ElMessage.success('已从匹配结果中移除')
|
ElMessage.success('已从匹配结果中移除')
|
||||||
@@ -942,6 +1024,185 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setLoopRun(next: PriceTrackLoopRunVo | null) {
|
||||||
|
activeLoopRun.value = next
|
||||||
|
activeLoopRunId.value = next?.id ?? null
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLoopRunIfTerminal() {
|
||||||
|
if (!activeLoopRun.value) return
|
||||||
|
const status = activeLoopRun.value.status || ''
|
||||||
|
if (status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED') {
|
||||||
|
activeLoopRun.value = null
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncActiveLoopRun() {
|
||||||
|
if (!activeLoopRunId.value) return null
|
||||||
|
try {
|
||||||
|
const loop = await getPriceTrackLoopRun(activeLoopRunId.value)
|
||||||
|
setLoopRun(loop)
|
||||||
|
clearLoopRunIfTerminal()
|
||||||
|
return activeLoopRun.value
|
||||||
|
} catch {
|
||||||
|
activeLoopRun.value = null
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
|
||||||
|
taskSnapshots.value = {
|
||||||
|
...taskSnapshots.value,
|
||||||
|
[taskVo.taskId]: {
|
||||||
|
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||||
|
items: taskVo.items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
taskDetails.value = {
|
||||||
|
...taskDetails.value,
|
||||||
|
[taskVo.taskId]: 'RUNNING',
|
||||||
|
}
|
||||||
|
saveTaskSnapshotsToStorage()
|
||||||
|
saveTaskDetailsToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopLoopExecution() {
|
||||||
|
if (!activeLoopRunId.value || stopLoopPending.value) return
|
||||||
|
stopLoopPending.value = true
|
||||||
|
try {
|
||||||
|
const loop = await stopPriceTrackLoopRun(activeLoopRunId.value)
|
||||||
|
setLoopRun(loop)
|
||||||
|
clearLoopRunIfTerminal()
|
||||||
|
ElMessage.success(loop.status === 'STOPPED' ? '循环已停止' : '已请求停止,当前子任务完成后将收尾')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '停止循环失败')
|
||||||
|
} finally {
|
||||||
|
stopLoopPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLoopExecution(loopId?: number) {
|
||||||
|
if (loopDispatching.value) return
|
||||||
|
if (loopId && activeLoopRunId.value !== loopId) {
|
||||||
|
activeLoopRunId.value = loopId
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
}
|
||||||
|
if (!activeLoopRunId.value) return
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.enqueue_json) {
|
||||||
|
throw new Error('当前环境未启用 pywebview enqueue_json')
|
||||||
|
}
|
||||||
|
loopDispatching.value = true
|
||||||
|
try {
|
||||||
|
while (activeLoopRunId.value) {
|
||||||
|
const loop = await syncActiveLoopRun()
|
||||||
|
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||||
|
|
||||||
|
if (loop.activeTaskId) {
|
||||||
|
addPollingTask(loop.activeTaskId)
|
||||||
|
scheduleNextPoll(true)
|
||||||
|
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
|
||||||
|
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
|
||||||
|
setLoopRun(updatedLoop)
|
||||||
|
await loadHistory()
|
||||||
|
await loadDashboard()
|
||||||
|
syncPollingIdsWithHistory()
|
||||||
|
clearLoopRunIfTerminal()
|
||||||
|
if (finalStatus !== 'SUCCESS') {
|
||||||
|
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
|
||||||
|
setLoopRun(dispatch.loopRun || loop)
|
||||||
|
if (!dispatch.childTaskRequest) {
|
||||||
|
clearLoopRunIfTerminal()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const row = dispatch.childTaskRequest.items?.[0]
|
||||||
|
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||||||
|
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
|
||||||
|
recordCreatedTask(taskVo)
|
||||||
|
const queuePayload = buildQueuePayload(taskVo, row)
|
||||||
|
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||||
|
const pushResult = await api.enqueue_json(queuePayload)
|
||||||
|
if (!pushResult?.success) {
|
||||||
|
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
|
||||||
|
}
|
||||||
|
addPollingTask(taskVo.taskId)
|
||||||
|
scheduleNextPoll(true)
|
||||||
|
removeMatchedRowsLocally([row])
|
||||||
|
queuePushResult.value = `循环任务 ${loop.id} 第 ${dispatch.childTaskRequest.roundIndex || 1} 轮已推送:${row.shopName || taskVo.taskId}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loopDispatching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushToPythonLoopQueue() {
|
||||||
|
if (hasRunningLoop.value) {
|
||||||
|
try {
|
||||||
|
await runLoopExecution(activeLoopRunId.value || undefined)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && /子任务不存在|循环任务不存在|404/.test(error.message)) {
|
||||||
|
activeLoopRun.value = null
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : '循环执行失败'
|
||||||
|
queuePushResult.value = message
|
||||||
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasValidMode.value) {
|
||||||
|
ElMessage.warning('请至少选择一个跟价模式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||||
|
ElMessage.warning('请先选择 ASIN 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const matchedRows = matchedItems.value.filter((item) => item.matched)
|
||||||
|
if (!matchedRows.length) {
|
||||||
|
ElMessage.warning('请先匹配可用店铺')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushing.value = true
|
||||||
|
queuePayloadText.value = ''
|
||||||
|
try {
|
||||||
|
const loop = await createPriceTrackLoopRun({
|
||||||
|
userId: 0,
|
||||||
|
statusMode: statusModeEnabled.value,
|
||||||
|
asinMode: asinModeEnabled.value,
|
||||||
|
items: matchedRows,
|
||||||
|
asinFiles: asinFiles.value,
|
||||||
|
countryCodes: [...orderedCountryCodes.value],
|
||||||
|
executionMode: executionMode.value,
|
||||||
|
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||||
|
})
|
||||||
|
setLoopRun(loop)
|
||||||
|
queuePushResult.value = `循环任务 ${loop.id} 已创建`
|
||||||
|
await runLoopExecution(loop.id)
|
||||||
|
clearLoopRunIfTerminal()
|
||||||
|
if (!activeLoopRunId.value) {
|
||||||
|
ElMessage.success('跟价循环已启动')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '启动循环失败'
|
||||||
|
queuePushResult.value = message
|
||||||
|
ElMessage.error(message)
|
||||||
|
} finally {
|
||||||
|
pushing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||||
}
|
}
|
||||||
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
|||||||
} else if (item.taskId != null) {
|
} else if (item.taskId != null) {
|
||||||
await deletePriceTrackTask(item.taskId)
|
await deletePriceTrackTask(item.taskId)
|
||||||
}
|
}
|
||||||
|
if (activeLoopRun.value && ((item.loopRunId != null && item.loopRunId === activeLoopRun.value.id) || (item.taskId != null && item.taskId === activeLoopRun.value.activeTaskId))) {
|
||||||
|
await syncActiveLoopRun()
|
||||||
|
}
|
||||||
if (item.taskId != null) removePollingTask(item.taskId)
|
if (item.taskId != null) removePollingTask(item.taskId)
|
||||||
removeMatchedRowsLocally([item])
|
removeMatchedRowsLocally([item])
|
||||||
await loadHistory()
|
await loadHistory()
|
||||||
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
|||||||
|
|
||||||
// ========== 生命周期 ==========
|
// ========== 生命周期 ==========
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (!statusModeEnabled.value && !asinModeEnabled.value) {
|
||||||
|
selectMode('status')
|
||||||
|
}
|
||||||
loadPollingIdsFromStorage()
|
loadPollingIdsFromStorage()
|
||||||
loadTaskDetailsFromStorage()
|
loadTaskDetailsFromStorage()
|
||||||
loadTaskSnapshotsFromStorage()
|
loadTaskSnapshotsFromStorage()
|
||||||
loadMatchedItemsFromStorage()
|
loadMatchedItemsFromStorage()
|
||||||
|
loadActiveLoopRunIdFromStorage()
|
||||||
loadCandidates().catch(() => undefined)
|
loadCandidates().catch(() => undefined)
|
||||||
loadCountryPreference().catch(() => undefined)
|
loadCountryPreference().catch(() => undefined)
|
||||||
loadHistory().catch(() => undefined)
|
loadHistory().catch(() => undefined)
|
||||||
loadDashboard().catch(() => undefined)
|
loadDashboard().catch(() => undefined)
|
||||||
if (pollingTaskIds.value.length) scheduleNextPoll(true)
|
if (pollingTaskIds.value.length) scheduleNextPoll(true)
|
||||||
|
if (activeLoopRunId.value) {
|
||||||
|
void syncActiveLoopRun().then((loop) => {
|
||||||
|
if (loop?.activeTaskId) {
|
||||||
|
addPollingTask(loop.activeTaskId)
|
||||||
|
scheduleNextPoll(true)
|
||||||
|
}
|
||||||
|
if (loop && loop.status !== 'SUCCESS' && loop.status !== 'FAILED' && loop.status !== 'STOPPED') {
|
||||||
|
void runLoopExecution(loop.id)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
activeLoopRunId.value = null
|
||||||
|
saveActiveLoopRunId()
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loop-round-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-round-input :deep(.el-input-number) {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-round-input :deep(.el-input__wrapper) {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-badge {
|
.tag-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loop-status-card {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2f3d2f;
|
||||||
|
background: rgba(39, 174, 96, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-status-line {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #d6e6d8;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-error {
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop-stop-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-msg code {
|
.loading-msg code {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #8fd3ff;
|
color: #8fd3ff;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
<nav class="nav-sections" aria-label="顶部功能导航">
|
<nav class="nav-sections" aria-label="顶部功能导航">
|
||||||
<section
|
<section
|
||||||
v-for="group in navGroups"
|
v-for="group in visibleNavGroups"
|
||||||
:key="group.label"
|
:key="group.columnKey"
|
||||||
class="nav-section"
|
class="nav-section"
|
||||||
>
|
>
|
||||||
<div class="nav-section-title">{{ group.label }}</div>
|
<div class="nav-section-title">{{ group.label }}</div>
|
||||||
@@ -39,6 +39,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||||
|
|
||||||
type ActiveNavKey =
|
type ActiveNavKey =
|
||||||
| 'brand'
|
| 'brand'
|
||||||
| 'dedupe'
|
| 'dedupe'
|
||||||
@@ -55,14 +59,22 @@ type NavItem = {
|
|||||||
href?: string
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavGroup = {
|
||||||
|
columnKey: string
|
||||||
|
label: string
|
||||||
|
items: ReadonlyArray<NavItem>
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
active: ActiveNavKey
|
active: ActiveNavKey
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const active = props.active
|
const active = props.active
|
||||||
|
const allowedColumnKeys = ref<string[] | null>(null)
|
||||||
|
|
||||||
const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }> = [
|
const navGroups: ReadonlyArray<NavGroup> = [
|
||||||
{
|
{
|
||||||
|
columnKey: 'brand_front_tools',
|
||||||
label: '前端工具',
|
label: '前端工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'collect', label: '采集数据' },
|
{ key: 'collect', label: '采集数据' },
|
||||||
@@ -74,6 +86,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
columnKey: 'brand_operation_tools',
|
||||||
label: '运营工具',
|
label: '运营工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
||||||
@@ -86,6 +99,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
columnKey: 'brand_logistics_tools',
|
||||||
label: '后勤工具',
|
label: '后勤工具',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'purchase', label: '采购' },
|
{ key: 'purchase', label: '采购' },
|
||||||
@@ -93,6 +107,22 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const visibleNavGroups = computed(() => {
|
||||||
|
if (allowedColumnKeys.value === null) {
|
||||||
|
return navGroups
|
||||||
|
}
|
||||||
|
const allowedSet = new Set(allowedColumnKeys.value)
|
||||||
|
return navGroups.filter((group) => allowedSet.has(group.columnKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
allowedColumnKeys.value = await getCurrentUserAppColumnKeys()
|
||||||
|
} catch (_error) {
|
||||||
|
allowedColumnKeys.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -955,6 +955,8 @@ export interface PriceTrackDashboardVo {
|
|||||||
export interface PriceTrackHistoryItem {
|
export interface PriceTrackHistoryItem {
|
||||||
resultId?: number;
|
resultId?: number;
|
||||||
taskId?: number;
|
taskId?: number;
|
||||||
|
loopRunId?: number;
|
||||||
|
roundIndex?: number;
|
||||||
shopName?: string;
|
shopName?: string;
|
||||||
shopId?: number | string | null;
|
shopId?: number | string | null;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -998,6 +1000,8 @@ export interface PriceTrackTaskSummary {
|
|||||||
id?: number;
|
id?: number;
|
||||||
taskNo?: string;
|
taskNo?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
loopRunId?: number;
|
||||||
|
roundIndex?: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -1146,6 +1150,45 @@ export interface PriceTrackCreateTaskRequest {
|
|||||||
items: PriceTrackShopQueueItem[];
|
items: PriceTrackShopQueueItem[];
|
||||||
asinFiles: string[];
|
asinFiles: string[];
|
||||||
countryCodes: string[];
|
countryCodes: string[];
|
||||||
|
loopRunId?: number;
|
||||||
|
roundIndex?: number;
|
||||||
|
shopIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PriceTrackExecutionMode = "FINITE" | "INFINITE";
|
||||||
|
|
||||||
|
export interface PriceTrackLoopRunCreateRequest {
|
||||||
|
userId: number;
|
||||||
|
statusMode: boolean;
|
||||||
|
asinMode: boolean;
|
||||||
|
items: PriceTrackShopQueueItem[];
|
||||||
|
asinFiles: string[];
|
||||||
|
countryCodes: string[];
|
||||||
|
executionMode: PriceTrackExecutionMode;
|
||||||
|
targetRounds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceTrackLoopRunVo {
|
||||||
|
id: number;
|
||||||
|
status?: string;
|
||||||
|
executionMode?: PriceTrackExecutionMode;
|
||||||
|
targetRounds?: number;
|
||||||
|
currentRound?: number;
|
||||||
|
currentShopIndex?: number;
|
||||||
|
totalShopCount?: number;
|
||||||
|
activeTaskId?: number;
|
||||||
|
activeTaskStatus?: string;
|
||||||
|
stopRequested?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
statusMode?: boolean;
|
||||||
|
asinMode?: boolean;
|
||||||
|
asinFiles?: string[];
|
||||||
|
countryCodes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceTrackLoopRunDispatchVo {
|
||||||
|
loopRun?: PriceTrackLoopRunVo;
|
||||||
|
childTaskRequest?: PriceTrackCreateTaskRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPriceTrackTask(
|
export function createPriceTrackTask(
|
||||||
@@ -1176,6 +1219,52 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPriceTrackLoopRun(
|
||||||
|
request: Omit<PriceTrackLoopRunCreateRequest, "userId"> | PriceTrackLoopRunCreateRequest,
|
||||||
|
) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PriceTrackLoopRunVo>, PriceTrackLoopRunCreateRequest>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/loop-runs`,
|
||||||
|
{ ...request, userId: getCurrentUserId() },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPriceTrackLoopRun(loopRunId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
get<JavaApiResponse<PriceTrackLoopRunVo>>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchNextPriceTrackLoopRun(loopRunId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PriceTrackLoopRunDispatchVo>, undefined>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/dispatch-next?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completePriceTrackLoopChild(loopRunId: number, childTaskId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PriceTrackLoopRunVo>, { childTaskId: number }>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/child-finished?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||||
|
{ childTaskId },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPriceTrackLoopRun(loopRunId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PriceTrackLoopRunVo>, undefined>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/stop?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||||
return unwrapJavaResponse(
|
return unwrapJavaResponse(
|
||||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||||
|
|||||||
63
frontend-vue/src/shared/api/permission.ts
Normal file
63
frontend-vue/src/shared/api/permission.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { requestGetJson } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface PermissionMenuItem {
|
||||||
|
id: number | string
|
||||||
|
name?: string
|
||||||
|
column_key?: string
|
||||||
|
route_path?: string
|
||||||
|
menu_type?: string
|
||||||
|
sort_order?: number
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionMenuResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: PermissionMenuItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUserId() {
|
||||||
|
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new Error('未获取到用户ID')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppPermissionCacheKey(uid: number) {
|
||||||
|
return `app_column_permissions:${String(uid)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserAppColumnKeys() {
|
||||||
|
const uid = getCurrentUserId()
|
||||||
|
const cacheKey = getAppPermissionCacheKey(uid)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
|
||||||
|
if (Array.isArray(cachedItems)) {
|
||||||
|
return cachedItems
|
||||||
|
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
const res = await requestGetJson<PermissionMenuResponse>(
|
||||||
|
`/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`,
|
||||||
|
{
|
||||||
|
params: { menu_type: 'app' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.error || '获取菜单权限失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(cacheKey, JSON.stringify(res.items || []))
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
return (res.items || [])
|
||||||
|
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user