提交bug修复 后台权限管理

This commit is contained in:
super
2026-04-11 22:25:14 +08:00
parent 73392a9b83
commit 365050b890
45 changed files with 972 additions and 375 deletions

View File

@@ -14,7 +14,6 @@ client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18081
# java_api_base=http://8.136.19.173:18080

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C_Si_gmj.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CyZr-IbT.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C_Si_gmj.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CyZr-IbT.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C_Si_gmj.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CyZr-IbT.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-Dl7T0pmm.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C_Si_gmj.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CyZr-IbT.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head>
<body>

Binary file not shown.

View File

@@ -32,6 +32,6 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
log.error("Unhandled exception", ex);
return ApiResponse.fail("服务异常" + ex.getMessage());
return ApiResponse.fail("服务异常: " + ex.getMessage());
}
}

View File

@@ -127,7 +127,7 @@ public class DeleteBrandRunService {
ZiniaoShopMatchResultVo matchResult = normalizedShopName.isBlank()
? ziniaoShopSwitchService.emptyMatchResult()
: storeMatchByShopName.computeIfAbsent(normalizedShopName,
ignored -> ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName()));
ignored -> ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName(), false));
item.setMatchStatus(matchResult.getMatchStatus());
item.setMatchMessage(matchResult.getMatchMessage());
item.setMatched(matchResult.isMatched());
@@ -714,7 +714,7 @@ public class DeleteBrandRunService {
unmatchedCount++;
continue;
}
ZiniaoShopMatchResultVo refreshed = ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName());
ZiniaoShopMatchResultVo refreshed = ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName(), false);
if (refreshed != null) {
String oldStatus = item.getMatchStatus();
boolean oldMatched = item.isMatched();
@@ -990,6 +990,26 @@ public class DeleteBrandRunService {
Map<String, List<DeleteBrandResultFileDto>> mergedByFile = loadMergedChunks(taskId);
int finishedFiles = countCompletedFiles(mergedByFile);
if (finishedFiles < expectedFiles) {
Map<String, String> progress = new LinkedHashMap<>();
progress.put("finished_files", String.valueOf(finishedFiles));
// 定时补偿只负责“再试一次 finalize”不能把缺片任务重新续命
// 否则 stale-check 永远看不到超时任务。
if (!fromCompensation) {
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
}
deleteBrandTaskCacheService.saveProgress(taskId, progress);
task.setSuccessFileCount(finishedFiles);
if (!fromCompensation) {
task.setUpdatedAt(LocalDateTime.now());
}
fileTaskMapper.updateById(task);
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return;
}
Map<String, String> progress = new LinkedHashMap<>();
progress.put("finished_files", String.valueOf(finishedFiles));
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
@@ -1001,11 +1021,6 @@ public class DeleteBrandRunService {
task.setSuccessFileCount(finishedFiles);
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
if (finishedFiles < expectedFiles) {
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return;
}
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);

View File

@@ -20,7 +20,8 @@ import java.time.ZoneId;
import java.util.Map;
/**
* 鍒犻櫎鍝佺墝浠诲姟鐨勮秴鏃跺洖鏀朵笌 finalize 琛ュ伩锛涘苟涓庡垹闄ゅ搧鐗屽叡鐢ㄥ悓涓€濂楀畾鏃惰皟搴︼紝瀵瑰晢鍝侀闄╋紙PRODUCT_RISK_RESOLVE锛夐暱鏃堕棿鏃犲洖浼犲仛澶辫触鏀跺熬銆?
* 删除品牌任务的超时回收与 finalize 补偿。
* 同时复用这套定时调度,对商品风险和匹配店铺任务做超时兜底收尾。
*/
@Service
@RequiredArgsConstructor
@@ -66,7 +67,7 @@ public class DeleteBrandStaleTaskService {
private void failStaleDeleteBrandTasks() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(deleteBrandProgressProperties.getHeartbeatTimeoutMinutes());
// 鍏堟寜 DB updatedAt 绮楃瓫锛岄伩鍏嶅叏琛ㄦ壂鎻?
// 先按 DB updatedAt 粗筛,避免全表扫描。
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
@@ -96,19 +97,17 @@ public class DeleteBrandStaleTaskService {
}
LocalDateTime lastActivityTime;
// 鍙湁褰?has_progress 涓?true 涓旇秴杩?15 鍒嗛挓娌″績璺筹紝鎵嶈涓烘槸寮傚父鍗℃
// 只有 has_progress=true 且心跳超时,才判定为异常卡住。
if (lastHeartbeatAt > 0 && isActive) {
// 鏈夊績璺筹紝鐢ㄦ渶鍚庡績璺虫椂闂村姣?15 鍒嗛挓 (threshold 鏄?now - 15)
lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
if (lastActivityTime.isAfter(threshold)) {
continue; // 娌¤秴鏃?
continue;
}
} else {
// 娌℃湁蹇冭烦锛屾垨鑰呭綋鍓嶅鍦?has_progress: false 锛堢瓑寰呯敤鎴风偣鍑讳笅涓€涓换鍔℃帹閫佺殑闈欓粯鏈燂級
// 闈欓粯鏈熷拰鎺掗槦鏈熺殑瀹介檺鏈熶竴鏍凤紝璁句负 12 涓皬鏃?
// 没有心跳,或仍处于静默等待阶段时,使用更长的初始宽限期。
LocalDateTime queuedThreshold = LocalDateTime.now().minusHours(12);
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(queuedThreshold)) {
continue; // 绛夊緟涓嬩釜闃熷垪鎺ㄩ€佷腑锛屼笉瑕佹潃瀹冿紒
continue;
}
}
@@ -117,7 +116,7 @@ public class DeleteBrandStaleTaskService {
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "缁撴灉鍥炰紶闀挎椂闂存棤鍝嶅簲锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getErrorMessage, "结果回传长时间无响应,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
@@ -170,7 +169,7 @@ public class DeleteBrandStaleTaskService {
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
@@ -252,7 +251,7 @@ public class DeleteBrandStaleTaskService {
try {
deleteBrandRunService.tryFinalizeTask(task.getId(), true);
} catch (Exception ignored) {
// 瀹氭椂琛ュ伩涓嶅奖鍝嶄富娴佺▼锛涗笅娆¤皟搴︽垨浜哄伐閲嶈瘯鏃跺啀澶勭悊
// 定时补偿不影响主流程;下次调度或人工重试时再处理。
}
}
}
@@ -271,4 +270,3 @@ public class DeleteBrandStaleTaskService {
private int skippedTaskCount;
}
}

View File

@@ -0,0 +1,83 @@
package com.nanri.aiimage.modules.permission.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuCreateRequest;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuUpdateRequest;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
@Tag(name = "菜单权限管理", description = "统一维护软件端与后台管理端的菜单权限配置")
public class PermissionMenuController {
private final PermissionMenuService permissionMenuService;
@GetMapping("/permission-menus")
@Operation(summary = "查询菜单权限列表")
public ApiResponse<List<PermissionMenuItemVo>> listMenus(
@Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) {
return ApiResponse.success(permissionMenuService.list(menuType));
}
@PostMapping("/permission-menus")
@Operation(summary = "新增菜单权限项")
public ApiResponse<PermissionMenuItemVo> createMenu(@Valid @RequestBody PermissionMenuCreateRequest request) {
return ApiResponse.success("创建成功", permissionMenuService.create(request));
}
@PutMapping("/permission-menus/{id}")
@Operation(summary = "编辑菜单权限项")
public ApiResponse<PermissionMenuItemVo> updateMenu(@PathVariable Long id,
@Valid @RequestBody PermissionMenuUpdateRequest request) {
return ApiResponse.success("更新成功", permissionMenuService.update(id, request));
}
@DeleteMapping("/permission-menus/{id}")
@Operation(summary = "删除菜单权限项")
public ApiResponse<Void> deleteMenu(@PathVariable Long id) {
permissionMenuService.delete(id);
return ApiResponse.success("删除成功", null);
}
@GetMapping("/permission-users/{userId}/columns")
@Operation(summary = "查询用户菜单权限 ID 列表")
public ApiResponse<UserColumnIdsVo> getUserColumnIds(@PathVariable Long userId,
@RequestParam(required = false) String menuType) {
return ApiResponse.success(permissionMenuService.getUserColumnIds(userId, menuType));
}
@PutMapping("/permission-users/{userId}/columns")
@Operation(summary = "更新用户菜单权限")
public ApiResponse<Void> updateUserColumnIds(@PathVariable Long userId,
@RequestBody(required = false) UserColumnPermissionUpdateRequest request) {
permissionMenuService.updateUserColumnPermissions(userId, request);
return ApiResponse.success("保存成功", null);
}
@GetMapping("/permission-users/{userId}/column-permissions")
@Operation(summary = "查询用户菜单权限详情")
public ApiResponse<List<PermissionMenuItemVo>> getUserColumnPermissions(@PathVariable Long userId,
@RequestParam(required = false) String menuType) {
return ApiResponse.success(permissionMenuService.getUserColumnPermissions(userId, menuType));
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.permission.model.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class PermissionMenuCreateRequest {
@NotBlank(message = "菜单名称不能为空")
private String name;
@NotBlank(message = "菜单标识不能为空")
private String columnKey;
@NotBlank(message = "菜单类型不能为空")
private String menuType;
@NotBlank(message = "菜单路由不能为空")
private String routePath;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.permission.model.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class PermissionMenuUpdateRequest {
@NotBlank(message = "菜单名称不能为空")
private String name;
@NotBlank(message = "菜单标识不能为空")
private String columnKey;
@NotBlank(message = "菜单类型不能为空")
private String menuType;
@NotBlank(message = "菜单路由不能为空")
private String routePath;
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.permission.model.dto;
import lombok.Data;
import java.util.List;
@Data
public class UserColumnPermissionUpdateRequest {
private List<Long> columnIds;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.permission.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("users")
public class AdminUserEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String role;
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.permission.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("columns")
public class PermissionMenuEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String columnKey;
private String menuType;
private String routePath;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.permission.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("user_column_permission")
public class UserColumnPermissionEntity {
@TableField("user_id")
private Long userId;
@TableField("column_id")
private Long columnId;
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.permission.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class PermissionMenuItemVo {
private Long id;
private String name;
private String columnKey;
private String menuType;
private String routePath;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.permission.model.vo;
import lombok.Data;
import java.util.List;
@Data
public class UserColumnIdsVo {
private List<Long> columnIds;
}

View File

@@ -0,0 +1,55 @@
package com.nanri.aiimage.modules.permission.service;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class PermissionMenuSchemaInitializer {
private final JdbcTemplate jdbcTemplate;
@PostConstruct
public void initialize() {
executeQuietly("""
CREATE TABLE IF NOT EXISTS columns (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL COMMENT '栏目名',
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_column_key (column_key)
)
""");
executeQuietly("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key");
executeQuietly("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type");
executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''");
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path)
SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage'
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = 'admin_shop_manage'
)
""");
executeQuietly("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
column_id INT NOT NULL,
PRIMARY KEY (user_id, column_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
)
""");
}
private void executeQuietly(String sql) {
try {
jdbcTemplate.execute(sql);
} catch (Exception ex) {
log.debug("[permission-menu] schema init skipped: {}", ex.getMessage());
}
}
}

View File

@@ -0,0 +1,253 @@
package com.nanri.aiimage.modules.permission.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.mapper.PermissionMenuMapper;
import com.nanri.aiimage.modules.permission.mapper.UserColumnPermissionMapper;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuCreateRequest;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuUpdateRequest;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class PermissionMenuService {
public static final String MENU_TYPE_APP = "app";
public static final String MENU_TYPE_ADMIN = "admin";
private final PermissionMenuMapper permissionMenuMapper;
private final UserColumnPermissionMapper userColumnPermissionMapper;
private final AdminUserMapper adminUserMapper;
public List<PermissionMenuItemVo> list(String menuType) {
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
.eq(isValidMenuType(menuType), PermissionMenuEntity::getMenuType, normalizeMenuType(menuType))
.orderByAsc(PermissionMenuEntity::getId);
return permissionMenuMapper.selectList(query).stream()
.map(this::toItemVo)
.toList();
}
@Transactional
public PermissionMenuItemVo create(PermissionMenuCreateRequest request) {
String name = normalizeRequired(request.getName(), "菜单名称不能为空");
String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空");
String menuType = normalizeMenuTypeRequired(request.getMenuType());
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
ensureUniqueColumnKey(columnKey, null);
PermissionMenuEntity entity = new PermissionMenuEntity();
entity.setName(name);
entity.setColumnKey(columnKey);
entity.setMenuType(menuType);
entity.setRoutePath(routePath);
permissionMenuMapper.insert(entity);
return toItemVo(getMenuById(entity.getId()));
}
@Transactional
public PermissionMenuItemVo update(Long id, PermissionMenuUpdateRequest request) {
PermissionMenuEntity entity = getMenuById(id);
String name = normalizeRequired(request.getName(), "菜单名称不能为空");
String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空");
String menuType = normalizeMenuTypeRequired(request.getMenuType());
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
ensureUniqueColumnKey(columnKey, id);
entity.setName(name);
entity.setColumnKey(columnKey);
entity.setMenuType(menuType);
entity.setRoutePath(routePath);
permissionMenuMapper.updateById(entity);
return toItemVo(getMenuById(id));
}
@Transactional
public void delete(Long id) {
PermissionMenuEntity entity = getMenuById(id);
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
.eq(UserColumnPermissionEntity::getColumnId, entity.getId()));
permissionMenuMapper.deleteById(entity.getId());
}
public UserColumnIdsVo getUserColumnIds(Long userId, String menuType) {
AdminUserEntity user = getUserById(userId);
List<Long> columnIds;
if (isSuperAdmin(user)) {
columnIds = list(menuType).stream().map(PermissionMenuItemVo::getId).toList();
} else {
List<Long> assignedIds = userColumnPermissionMapper.selectList(
new LambdaQueryWrapper<UserColumnPermissionEntity>()
.eq(UserColumnPermissionEntity::getUserId, userId))
.stream()
.map(UserColumnPermissionEntity::getColumnId)
.distinct()
.toList();
if (assignedIds.isEmpty()) {
columnIds = List.of();
} else if (isValidMenuType(menuType)) {
Set<Long> allowedIds = list(menuType).stream()
.map(PermissionMenuItemVo::getId)
.collect(Collectors.toSet());
columnIds = assignedIds.stream().filter(allowedIds::contains).toList();
} else {
columnIds = assignedIds;
}
}
UserColumnIdsVo vo = new UserColumnIdsVo();
vo.setColumnIds(columnIds);
return vo;
}
public List<PermissionMenuItemVo> getUserColumnPermissions(Long userId, String menuType) {
AdminUserEntity user = getUserById(userId);
if (isSuperAdmin(user)) {
return list(menuType);
}
List<Long> assignedIds = userColumnPermissionMapper.selectList(
new LambdaQueryWrapper<UserColumnPermissionEntity>()
.eq(UserColumnPermissionEntity::getUserId, userId))
.stream()
.map(UserColumnPermissionEntity::getColumnId)
.distinct()
.toList();
if (assignedIds.isEmpty()) {
return List.of();
}
List<PermissionMenuEntity> menus = permissionMenuMapper.selectBatchIds(assignedIds);
Map<Long, PermissionMenuEntity> menuMap = menus.stream()
.collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity()));
String safeMenuType = normalizeMenuType(menuType);
return assignedIds.stream()
.map(menuMap::get)
.filter(menu -> menu != null)
.filter(menu -> safeMenuType == null || safeMenuType.equals(menu.getMenuType()))
.map(this::toItemVo)
.toList();
}
@Transactional
public void updateUserColumnPermissions(Long userId, UserColumnPermissionUpdateRequest request) {
getUserById(userId);
List<Long> requestedIds = request == null ? List.of() : normalizeColumnIds(request.getColumnIds());
if (!requestedIds.isEmpty()) {
Long validCount = permissionMenuMapper.selectCount(new LambdaQueryWrapper<PermissionMenuEntity>()
.in(PermissionMenuEntity::getId, requestedIds));
if (validCount == null || validCount != requestedIds.size()) {
throw new BusinessException("存在无效的菜单权限项");
}
}
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
.eq(UserColumnPermissionEntity::getUserId, userId));
for (Long columnId : requestedIds) {
UserColumnPermissionEntity entity = new UserColumnPermissionEntity();
entity.setUserId(userId);
entity.setColumnId(columnId);
userColumnPermissionMapper.insert(entity);
}
}
private PermissionMenuEntity getMenuById(Long id) {
PermissionMenuEntity entity = permissionMenuMapper.selectById(id);
if (entity == null) {
throw new BusinessException("菜单权限项不存在");
}
return entity;
}
private AdminUserEntity getUserById(Long userId) {
AdminUserEntity entity = adminUserMapper.selectById(userId);
if (entity == null) {
throw new BusinessException("用户不存在");
}
return entity;
}
private boolean isSuperAdmin(AdminUserEntity user) {
return user != null && "super_admin".equalsIgnoreCase(normalizeRequired(user.getRole(), ""));
}
private void ensureUniqueColumnKey(String columnKey, Long excludeId) {
PermissionMenuEntity exists = permissionMenuMapper.selectOne(new LambdaQueryWrapper<PermissionMenuEntity>()
.eq(PermissionMenuEntity::getColumnKey, columnKey)
.last("LIMIT 1"));
if (exists != null && (excludeId == null || !excludeId.equals(exists.getId()))) {
throw new BusinessException("菜单标识已存在");
}
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty() && !message.isEmpty()) {
throw new BusinessException(message);
}
return normalized;
}
private boolean isValidMenuType(String menuType) {
return normalizeMenuType(menuType) != null;
}
private String normalizeMenuTypeRequired(String menuType) {
String normalized = normalizeMenuType(menuType);
if (normalized == null) {
throw new BusinessException("菜单类型仅支持 app 或 admin");
}
return normalized;
}
private String normalizeMenuType(String menuType) {
String normalized = menuType == null ? "" : menuType.trim().toLowerCase(Locale.ROOT);
if (normalized.isEmpty()) {
return null;
}
if (MENU_TYPE_APP.equals(normalized) || MENU_TYPE_ADMIN.equals(normalized)) {
return normalized;
}
return null;
}
private List<Long> normalizeColumnIds(List<Long> columnIds) {
if (columnIds == null || columnIds.isEmpty()) {
return List.of();
}
Set<Long> uniqueIds = new LinkedHashSet<>();
for (Long columnId : columnIds) {
if (columnId != null && columnId > 0) {
uniqueIds.add(columnId);
}
}
return new ArrayList<>(uniqueIds);
}
private PermissionMenuItemVo toItemVo(PermissionMenuEntity entity) {
PermissionMenuItemVo vo = new PermissionMenuItemVo();
vo.setId(entity.getId());
vo.setName(entity.getName());
vo.setColumnKey(entity.getColumnKey());
vo.setMenuType(entity.getMenuType());
vo.setRoutePath(entity.getRoutePath());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
}

View File

@@ -26,6 +26,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -36,8 +38,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import java.io.InputStream;
import java.net.URI;
@@ -50,214 +50,131 @@ import java.util.List;
@RequestMapping("/api/product-risk-resolve")
@Tag(
name = "商品风险解决",
description = "亚马逊商品风险处理备选店铺、紫鸟匹配、创建任务、Python 队列与回传、生成 zip。"
+ "查询类接口需带 Query 参数 user_id与登录用户一致")
description = "亚马逊商品风险处理备选店铺、紫鸟匹配、创建任务、Python 队列与回传、生成 zip。查询类接口需要携带 Query 参数 user_id。")
public class ProductRiskResolveController {
private final ProductRiskResolveService productRiskResolveService;
private final ProductRiskTaskService productRiskTaskService;
@GetMapping("/candidates")
@Operation(
summary = "查询备选店铺列表",
description = "返回当前用户在「商品风险」模块中已保存的备选店铺,按创建时间倒序。用于前端勾选后再调用匹配接口。")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在商品风险模块中已保存的备选店铺。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(
name = "user_id",
description = "当前用户 ID仅返回该用户下的备选店铺",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(
summary = "新增备选店铺",
description = "将店铺名写入用户备选表;店铺名会规范化去重。仅用于前端勾选与匹配流程,与任务/结果表无强绑定。")
@Operation(summary = "新增备选店铺", description = "将店铺名写入用户备选表,用于前端勾选和匹配流程。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(productRiskResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(
summary = "删除备选店铺",
description = "按主键删除一条备选记录;`id` 必须属于当前 `user_id`,否则报错。")
@Operation(summary = "删除备选店铺", description = "删除一条备选记录id 必须属于当前 user_id。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "备选店铺记录主键biz_product_risk_shop_candidate.id", example = "10")
@PathVariable Long id,
@Parameter(
name = "user_id",
description = "当前用户 ID用于校验数据归属",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
productRiskResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@GetMapping("/country-preference")
@Operation(
summary = "查询用户五国处理顺序",
description = "返回当前用户已保存的国家代码列表顺序即处理顺序未保存时返回默认DE、UK、FR、IT、ES全选")
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即处理顺序。")
public ApiResponse<ProductRiskCountryPreferenceVo> getCountryPreference(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskResolveService.getCountryPreference(userId));
}
@PutMapping("/country-preference")
@Operation(
summary = "保存用户五国处理顺序",
description = "写入 biz_product_risk_country_prefcountry_codes 为 ProductRiskCountryCode 枚举名,至少 1 个、无重复。")
@Operation(summary = "保存国家处理顺序", description = "保存当前用户的国家勾选与处理顺序。")
public ApiResponse<ProductRiskCountryPreferenceVo> saveCountryPreference(
@Valid @RequestBody ProductRiskCountryPreferenceSaveRequest request) {
return ApiResponse.success(productRiskResolveService.saveCountryPreference(request));
}
@PostMapping("/match-shops")
@Operation(
summary = "批量匹配店铺(紫鸟索引)",
description = "按店名查紫鸟索引,返回 shopId、platform、companyName、matchStatus 等items 可作队列 data.items。"
+ "未命中时 matched=false不可创建任务。")
@Operation(summary = "批量匹配店铺", description = "按店名查询紫鸟索引,返回 shopId、platform、companyName、matchStatus 等。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(productRiskResolveService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(
summary = "统计看板",
description = "汇总当前用户备选店铺数量商品风险模块下已结束任务数SUCCESS/FAILED其中成功任务数、失败任务数。")
@Operation(summary = "统计看板", description = "返回候选店铺数量、已结束任务数、成功任务数和失败任务数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(
summary = "处理记录列表(含进行中与已结束)",
description = "返回当前用户商品风险模块下各店结果行biz_file_result按创建时间倒序最多 100 条;"
+ "含 resultId、taskId、店名、任务状态、success、错误、输出文件名等。")
@Operation(summary = "处理记录列表", description = "返回当前用户商品风险模块下各店结果行,含运行中与历史记录。")
public ApiResponse<ProductRiskHistoryVo> history(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskTaskService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
@Operation(
summary = "删除单条店铺结果记录",
description = "删除一条 biz_file_result须为本模块且归属 user_id随后重算父任务无子结果则删任务。")
@Operation(summary = "删除单条店铺结果记录", description = "删除一条 biz_file_result并在后端同步重算父任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001")
@PathVariable Long resultId,
@Parameter(
name = "user_id",
description = "当前用户 ID用于校验该结果归属",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
productRiskTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks")
@Operation(
summary = "创建商品风险处理任务",
description = "推 Python 队列前调用:落库 RUNNING 任务及各店 biz_file_result响应 taskId 写入队列 data.taskId"
+ "items 含各店 resultId。请求 items 须全为 matched=true店名会去重。")
@Operation(summary = "创建商品风险处理任务", description = "落库 RUNNING 任务及各店占位结果,返回 taskId 与 items。")
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ProductRiskCreateTaskRequest request) {
return ApiResponse.success(productRiskTaskService.createTask(request));
}
@DeleteMapping("/tasks/{taskId}")
@Operation(
summary = "删除整条任务",
description = "删除该任务及下属全部结果行RUNNING/SUCCESS/FAILED 均可user_id 须与任务归属一致。")
@Operation(summary = "删除整条任务", description = "删除该任务及其下属全部结果行RUNNING/SUCCESS/FAILED 均可。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键 biz_file_task.id", example = "200")
@PathVariable Long taskId,
@Parameter(
name = "user_id",
description = "当前用户 ID须与任务归属一致",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
productRiskTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/pending-shop-result")
@Operation(
summary = "按店铺名删除运行中任务下的一条结果",
description = "仅在 RUNNING 任务下按规范化店名删一条 biz_file_result 并重算任务;无匹配时 removed=false。"
+ "用于前端从匹配列表移除店铺时同步后端占位。")
@Operation(summary = "按店铺名删除运行中结果", description = "仅在 RUNNING 任务下按规范化店名删除一条 biz_file_result并重算任务。")
public ApiResponse<ProductRiskPendingDeleteVo> deletePendingShopResult(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
@Parameter(
name = "shop_name",
description = "店铺名称(与匹配/创建任务时一致;服务端会按紫鸟规则规范化后再与库中 source_filename 比对)",
required = true,
in = ParameterIn.QUERY,
example = "某某店铺")
@Parameter(name = "shop_name", description = "店铺名称", required = true, in = ParameterIn.QUERY, example = "某某店铺")
@RequestParam("shop_name") String shopName) {
return ApiResponse.success(productRiskTaskService.deletePendingShopResult(userId, shopName));
}
@PostMapping("/tasks/batch")
@Operation(
summary = "批量查询任务详情(轮询)",
description = "按 taskIds 返回任务概要及各店结果;无效 id 记入 missingTaskIds。供前端轮询 RUNNING 至结束。")
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概览与各店结果,供前端轮询使用。")
public ApiResponse<ProductRiskTaskBatchVo> tasksBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(productRiskTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "Python 回传处理结果(可多次、可分批)",
description = "Python 调用;同一 taskId 可多次,未出现在 shops 中的店保持待处理"
+ "有 error 则标店失败;否则按 countries键为五国枚举 DE/FR/ES/IT/UK生成 xlsx/zip 上传 OSS"
+ "Excel 内仍为中文工作表名。全部店终态后任务 SUCCESS/FAILED。")
summary = "Python 回传处理结果",
description = "同一 taskId 可多次回传。成功时按 countries 生成 xlsx/zip 并上传 OSS全部店铺终态后任务进入 SUCCESS/FAILED")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,须为 RUNNING 状态", example = "200")
@PathVariable Long taskId,
@Parameter(description = "任务主键,需处于 RUNNING 状态", example = "200") @PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "按店铺回传:成功时 `countries` 的 key 为枚举 ProductRiskCountryCodeJSON 与枚举名一致DE、FR、ES、IT、UK"
+ "失败时填 `error`,可不传 `countries`",
description = "按店铺回传结果;失败时填写 error成功时填写 countries。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskSubmitResultRequest.class),
examples = {
@ExampleObject(
name = "成功(单店,五国含样例行)",
summary = "DE、UK 各一行,其余国可为空数组",
name = "成功示例",
summary = "DE、UK 各一行,其余国可为空数组",
value = """
{
"shops": [
@@ -269,7 +186,7 @@ public class ProductRiskResolveController {
"shopName": "模拟旗舰店A",
"productAsinSku": "B0DEMO1234",
"status": "风险待解除",
"done": "",
"done": true,
"removeAsin": "B0OLD5678",
"removeStatus": "排队中"
}
@@ -282,7 +199,7 @@ public class ProductRiskResolveController {
"shopName": "模拟旗舰店A",
"productAsinSku": "B0UKDEMO99",
"status": "正常",
"done": "",
"done": true,
"removeAsin": "",
"removeStatus": ""
}
@@ -293,7 +210,7 @@ public class ProductRiskResolveController {
}
"""),
@ExampleObject(
name = "失败(单店仅 error",
name = "失败示例",
summary = "该店标记失败,不生成 zip",
value = """
{
@@ -312,18 +229,10 @@ public class ProductRiskResolveController {
}
@GetMapping("/results/{resultId}/download")
@Operation(
summary = "流式下载单个店铺的 zip 结果",
description = "从 OSS 拉取 zip 以附件写入响应体(非 JSON须归属 user_id无文件则 404。")
@Operation(summary = "下载单个店铺的 zip 结果", description = "从 OSS 拉取 zip 并以附件流返回;需校验 user_id 归属。")
public void downloadResult(
@Parameter(description = "店铺结果行主键 biz_file_result.id", example = "1001")
@PathVariable Long resultId,
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId);

View File

@@ -201,11 +201,13 @@ public class ProductRiskTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
String oldStatus = task.getStatus();
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
log.warn("[product-risk] reconcile delete empty taskId={} oldStatus={} -> deleting task", taskId, oldStatus);
fileTaskMapper.deleteById(taskId);
return;
}
@@ -255,6 +257,8 @@ public class ProductRiskTaskService {
log.warn("[product-risk] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
log.warn("[product-risk] reconcile status changed taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
taskId, oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
productRiskTaskCacheService.deleteTaskCache(taskId);
@@ -384,8 +388,11 @@ public class ProductRiskTaskService {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
log.warn("[product-risk] submitResult rejected taskId={} status={} payloadShopCount={}",
taskId, task.getStatus(), request.getShops() == null ? 0 : request.getShops().size());
throw new BusinessException("任务已结束,拒绝重复提交");
}
String oldStatus = task.getStatus();
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -525,6 +532,9 @@ public class ProductRiskTaskService {
log.warn("[product-risk] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
log.warn("[product-risk] submitResult status evaluated taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} matchedShopCount={} skippedUnmatchedCount={} waitingCount={} assembledCount={} fallbackMatchedCount={} batchErrors={}",
taskId, oldStatus, task.getStatus(), ok, fail, allDone, matchedShopCount, skippedUnmatchedCount,
waitingCount, assembledCount, fallbackMatchedCount, batchErrors);
}
@Transactional
@@ -537,8 +547,11 @@ public class ProductRiskTaskService {
return false;
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
log.warn("[product-risk] stale finalize skipped taskId={} status={} fromCompensation={}",
taskId, task.getStatus(), fromCompensation);
return true;
}
String oldStatus = task.getStatus();
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -620,6 +633,8 @@ public class ProductRiskTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
updateTaskStatusFromLatestRows(task, latest, batchErrors);
log.warn("[product-risk] stale finalize status evaluated taskId={} oldStatus={} newStatus={} changed={} cachedPayloadCount={} fromCompensation={} batchErrors={}",
taskId, oldStatus, task.getStatus(), changed, cachedPayloadByShop.size(), fromCompensation, batchErrors);
return true;
}
@@ -677,6 +692,7 @@ public class ProductRiskTaskService {
private void updateTaskStatusFromLatestRows(FileTaskEntity task,
List<FileResultEntity> latest,
List<String> batchErrors) {
String oldStatus = task.getStatus();
int ok = 0;
int fail = 0;
List<String> allErrors = new ArrayList<>();
@@ -721,6 +737,8 @@ public class ProductRiskTaskService {
log.warn("[product-risk] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
log.warn("[product-risk] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} batchErrors={} errorMessage={}",
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, batchErrors, task.getErrorMessage());
}
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {

View File

@@ -61,7 +61,7 @@ public class ShopMatchTaskService {
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(candidateMapper.selectCount(new LambdaQueryWrapper<ShopMatchShopCandidateEntity>()
@@ -86,7 +86,7 @@ public class ShopMatchTaskService {
public ProductRiskHistoryVo listHistory(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
@@ -118,11 +118,11 @@ public class ShopMatchTaskService {
@Transactional
public void deleteHistory(Long resultId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
@@ -134,11 +134,11 @@ public class ShopMatchTaskService {
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -186,10 +186,10 @@ public class ShopMatchTaskService {
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("记录不存在");
}
if (entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
@@ -197,7 +197,7 @@ public class ShopMatchTaskService {
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
return entity.getResultFilename() != null && !entity.getResultFilename().isBlank()
? entity.getResultFilename()
@@ -207,18 +207,18 @@ public class ShopMatchTaskService {
@Transactional
public ProductRiskCreateTaskVo createTask(ShopMatchCreateTaskRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 涓嶈兘涓虹┖");
throw new BusinessException("items 不能为空");
}
List<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 涓嶈兘涓虹┖");
throw new BusinessException("items 不能为空");
}
for (ProductRiskShopQueueItemVo item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("瀛樺湪鏈尮閰嶅簵閾猴紝鏃犳硶鍒涘缓浠诲姟");
throw new BusinessException("存在未匹配店铺,无法创建任务");
}
}
@@ -254,7 +254,7 @@ public class ShopMatchTaskService {
for (ProductRiskShopQueueItemVo item : uniqueItems) {
String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务数据已失效,请刷新后重试");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
@@ -271,7 +271,7 @@ public class ShopMatchTaskService {
task.setRequestJson(objectMapper.writeValueAsString(persistedRequest));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) {
throw new BusinessException("Invalid request");
throw new BusinessException("task 不存在");
}
fileTaskMapper.updateById(task);
@@ -284,34 +284,34 @@ public class ShopMatchTaskService {
@Transactional
public void activateTask(Long taskId, Long userId, Integer stageIndex) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId is invalid");
throw new BusinessException("taskId 不合法");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
if (stageIndex == null || stageIndex < 0) {
throw new BusinessException("stage_index is invalid");
throw new BusinessException("stage_index 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务状态不正确");
}
if (!"SCHEDULED".equals(task.getStatus())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务状态不正确");
}
if (task.getScheduledAt() != null && LocalDateTime.now().isBefore(task.getScheduledAt())) {
throw new BusinessException("鏈埌瀹氭椂鎵ц鏃堕棿");
if (task.getScheduledAt() != null && LocalDateTime.now().isBefore(task.getScheduledAt().minusSeconds(90))) {
throw new BusinessException("未到定时执行时间");
}
ShopMatchCreateTaskRequest state = parseTaskRequest(task);
int currentStageIndex = state.getCurrentStageIndex() == null ? 0 : state.getCurrentStageIndex();
if (currentStageIndex != stageIndex) {
throw new BusinessException("褰撳墠杞宸插彉鍖栵紝璇峰埛鏂板悗閲嶈瘯");
throw new BusinessException("当前轮次已变化,请刷新后重试");
}
if (state.getScheduleTimes() == null || stageIndex >= state.getScheduleTimes().size()) {
throw new BusinessException("Invalid request");
throw new BusinessException("轮次配置缺失");
}
state.setActiveStageIndex(stageIndex);
task.setStatus("RUNNING");
@@ -323,28 +323,28 @@ public class ShopMatchTaskService {
@Transactional
public void completeStage(Long taskId, Long userId, ShopMatchStageCompleteRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId is invalid");
throw new BusinessException("taskId 不合法");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id is invalid");
throw new BusinessException("user_id 不合法");
}
if (request == null || request.getStageIndex() == null || request.getStageIndex() < 0) {
throw new BusinessException("stage_index is invalid");
throw new BusinessException("stage_index 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
if (!"RUNNING".equals(task.getStatus())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务状态不正确");
}
ShopMatchCreateTaskRequest state = parseTaskRequest(task);
if (state.getActiveStageIndex() == null || !state.getActiveStageIndex().equals(request.getStageIndex())) {
throw new BusinessException("杞鐘舵€佷笉鍖归厤");
throw new BusinessException("轮次状态不匹配");
}
int nextIndex = request.getStageIndex() + 1;
if (state.getScheduleTimes() == null || nextIndex >= state.getScheduleTimes().size()) {
throw new BusinessException("Current stage is already the final stage");
throw new BusinessException("当前轮次已经是最后一轮");
}
state.setCurrentStageIndex(nextIndex);
state.setActiveStageIndex(null);
@@ -360,18 +360,18 @@ public class ShopMatchTaskService {
@Transactional
public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId is invalid");
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 涓嶈兘涓虹┖");
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("Invalid request");
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("浠诲姟宸茬粨鏉燂紝鎷掔粷閲嶅鎻愪氦");
throw new BusinessException("任务已结束,拒绝重复提交");
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
@@ -408,7 +408,7 @@ public class ShopMatchTaskService {
continue;
}
if (countPayloadRows(merged) <= 0) {
markResultFailed(result, "No result rows received for assembly");
markResultFailed(result, "未收到可用于组装的结果数据");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
@@ -416,7 +416,7 @@ public class ShopMatchTaskService {
assembleShopResult(result, shopKey, merged, workRoot);
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
} catch (Exception ex) {
markResultFailed(result, ex.getMessage() == null ? "assemble failed" : ex.getMessage());
markResultFailed(result, ex.getMessage() == null ? "结果组装失败" : ex.getMessage());
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
}
}
@@ -468,8 +468,8 @@ public class ShopMatchTaskService {
ShopMatchShopPayloadDto cachedPayload = shopKey == null ? null : cachedPayloadByShop.get(shopKey);
if (cachedPayload == null) {
markResultFailed(result, "Python interrupted before this shop finished uploading results");
batchErrors.add(shopKey + ": Python interrupted before this shop finished uploading results");
markResultFailed(result, "Python 在该店铺结果上传完成前中断");
batchErrors.add(shopKey + ": Python 在该店铺结果上传完成前中断");
changed = true;
continue;
}
@@ -483,7 +483,7 @@ public class ShopMatchTaskService {
}
if (countPayloadRows(cachedPayload) <= 0 && payloadHasAnyDoneTrue(cachedPayload)) {
markResultFailed(result, "No result rows received for assembly");
markResultFailed(result, "未收到可用于组装的结果数据");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
log.warn("[shop-match] stale finalize finished without data taskId={} shop={} fromCompensation={}",
@@ -492,8 +492,8 @@ public class ShopMatchTaskService {
}
if (countPayloadRows(cachedPayload) <= 0) {
markResultFailed(result, "Python interrupted before any assembleable rows were uploaded");
batchErrors.add(shopKey + ": Python interrupted before any assembleable rows were uploaded");
markResultFailed(result, "Python 在上传可组装结果前中断");
batchErrors.add(shopKey + ": Python 在上传可组装结果前中断");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
changed = true;
continue;
@@ -506,7 +506,7 @@ public class ShopMatchTaskService {
log.warn("[shop-match] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={}",
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload));
} catch (Exception ex) {
String message = ex.getMessage() == null ? "assemble failed" : ex.getMessage();
String message = ex.getMessage() == null ? "结果组装失败" : ex.getMessage();
markResultFailed(result, message);
batchErrors.add(shopKey + ": " + message);
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
@@ -595,11 +595,11 @@ public class ShopMatchTaskService {
task.setFinishedAt(LocalDateTime.now());
} else if (ok > 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(String.join("; ", errors.isEmpty() ? batchErrors : errors));
task.setErrorMessage(String.join("", errors.isEmpty() ? batchErrors : errors));
task.setFinishedAt(LocalDateTime.now());
} else {
task.setStatus("FAILED");
task.setErrorMessage(errors.isEmpty() ? "all shops failed" : String.join("; ", errors));
task.setErrorMessage(errors.isEmpty() ? "全部店铺处理失败" : String.join("", errors));
task.setFinishedAt(LocalDateTime.now());
}
try {
@@ -633,7 +633,7 @@ public class ShopMatchTaskService {
.orderByAsc(FileResultEntity::getId));
List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity row : rows) {
throw new BusinessException("Task request payload is missing");
items.add(toHistoryItemVo(row, taskStatus));
}
return items;
}
@@ -778,7 +778,7 @@ public class ShopMatchTaskService {
private ShopMatchCreateTaskRequest parseTaskRequest(FileTaskEntity task) {
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request == null) {
throw new BusinessException("浠诲姟閰嶇疆缂哄け");
throw new BusinessException("任务配置缺失");
}
if (request.getItems() == null) {
request.setItems(new ArrayList<>());
@@ -807,7 +807,7 @@ public class ShopMatchTaskService {
try {
task.setRequestJson(objectMapper.writeValueAsString(request));
} catch (Exception ex) {
throw new BusinessException("Invalid request");
throw new BusinessException("序列化任务数据失败");
}
}
@@ -831,7 +831,7 @@ public class ShopMatchTaskService {
private List<String> normalizeCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 涓嶈兘涓虹┖");
throw new BusinessException("country_codes 不能为空");
}
LinkedHashSet<String> out = new LinkedHashSet<>();
for (String code : raw) {
@@ -842,12 +842,12 @@ public class ShopMatchTaskService {
try {
ProductRiskCountryCode.valueOf(normalized);
} catch (IllegalArgumentException ex) {
throw new BusinessException("闈炴硶鍥藉浠g爜: " + code);
throw new BusinessException("非法国家代码: " + code);
}
out.add(normalized);
}
if (out.isEmpty()) {
throw new BusinessException("country_codes 涓嶈兘涓虹┖");
throw new BusinessException("country_codes 不能为空");
}
return new ArrayList<>(out);
}
@@ -864,16 +864,16 @@ public class ShopMatchTaskService {
continue;
}
if (!time.isAfter(now)) {
throw new BusinessException("schedule_times 蹇呴』鏅氫簬褰撳墠鏃堕棿");
throw new BusinessException("schedule_times 必须晚于当前时间");
}
if (previous != null && !time.isAfter(previous)) {
throw new BusinessException("schedule_times 蹇呴』鎸夋椂闂撮€掑");
throw new BusinessException("schedule_times 必须按时间递增");
}
out.add(time);
previous = time;
}
if (out.isEmpty()) {
throw new BusinessException("schedule_times 涓嶈兘涓虹┖");
throw new BusinessException("schedule_times 不能为空");
}
return out;
}

View File

@@ -0,0 +1,9 @@
ALTER TABLE `columns`
ADD COLUMN `menu_type` VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER `column_key`,
ADD COLUMN `route_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER `menu_type`;
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`)
SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage'
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_shop_manage'
);

View File

@@ -70,6 +70,26 @@ def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None
return data, None, 200
def _format_permission_item(item):
return {
'id': item.get('id'),
'name': item.get('name') or '',
'column_key': item.get('columnKey') or '',
'menu_type': item.get('menuType') or 'app',
'route_path': item.get('routePath') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
}
def _sync_user_column_permissions(user_id, column_ids):
_, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/permission-users/{user_id}/columns',
json_data={'columnIds': [int(x) for x in (column_ids or []) if str(x).isdigit()]},
)
return error_response, status
# ---------- 用户管理 ----------
@admin_api.route('/users')
@@ -155,6 +175,7 @@ def list_users():
'total': total,
'page': page,
'page_size': page_size,
'current_user_id': session.get('user_id'),
'current_user_role': role,
'current_user_username': current_row.get('username') or '',
'admins': admins,
@@ -203,9 +224,7 @@ def create_user():
want_created_by = current_row['id']
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
column_ids = data.get('column_ids')
if column_ids is None:
column_ids = []
column_ids = data.get('column_ids') or []
try:
conn = get_db()
with conn.cursor() as cur:
@@ -214,9 +233,11 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by),
)
new_uid = cur.lastrowid
_set_user_column_permissions(cur, new_uid, column_ids)
conn.commit()
conn.close()
error_response, status = _sync_user_column_permissions(new_uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
@@ -270,10 +291,12 @@ def update_user(uid):
(is_admin, want_role, uid),
)
column_ids = data.get('column_ids')
if column_ids is not None:
_set_user_column_permissions(cur, uid, column_ids)
conn.commit()
conn.close()
if column_ids is not None:
error_response, status = _sync_user_column_permissions(uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@@ -388,156 +411,124 @@ def history():
@admin_api.route('/columns')
@admin_required
def list_columns():
"""获取栏目列表(用于栏目配置与用户权限选择)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, column_key, created_at FROM columns ORDER BY id"
)
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'] or '',
'column_key': r['column_key'] or '',
'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})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
params = {}
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/permission-menus',
params=params or None,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
@admin_api.route('/column', methods=['POST'])
@admin_required
def create_column():
"""新增栏目"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
route_path = (data.get('route_path') or '').strip()
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO columns (name, column_key) VALUES (%s, %s)",
(name, column_key),
)
cid = cur.lastrowid
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '创建成功', 'id': cid})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '栏目标识已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
if not route_path:
return jsonify({'success': False, 'error': '菜单路由不能为空'})
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/permission-menus',
json_data={
'name': name,
'columnKey': column_key,
'menuType': menu_type,
'routePath': route_path,
},
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({'success': True, 'msg': result.get('message') or '创建成功', 'id': item.get('id')})
@admin_api.route('/column/<int:cid>', methods=['PUT'])
@admin_required
def update_column(cid):
"""更新栏目"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
route_path = (data.get('route_path') or '').strip()
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"UPDATE columns SET name = %s, column_key = %s WHERE id = %s",
(name, column_key, cid),
)
if cur.rowcount == 0:
conn.close()
return jsonify({'success': False, 'error': '栏目不存在'})
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '栏目标识已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
if not route_path:
return jsonify({'success': False, 'error': '菜单路由不能为空'})
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/permission-menus/{cid}',
json_data={
'name': name,
'columnKey': column_key,
'menuType': menu_type,
'routePath': route_path,
},
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '更新成功'})
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
@admin_required
def delete_column(cid):
"""删除栏目(会同步删除用户栏目权限关联)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("DELETE FROM user_column_permission WHERE column_id = %s", (cid,))
cur.execute("DELETE FROM columns WHERE id = %s", (cid,))
if cur.rowcount == 0:
conn.close()
return jsonify({'success': False, 'error': '栏目不存在'})
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/permission-menus/{cid}',
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/user/<int:uid>/columns')
@admin_required
def get_user_columns(uid):
"""获取某用户的栏目权限 ID 列表(编辑用户时回显)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT column_id FROM user_column_permission WHERE user_id = %s",
(uid,),
)
rows = cur.fetchall()
conn.close()
column_ids = [r['column_id'] for r in rows]
return jsonify({'success': True, 'column_ids': column_ids})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
params = {}
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/columns',
params=params or None,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, 'column_ids': payload.get('columnIds') or []})
@admin_api.route('/user/<int:uid>/column-permissions')
@admin_required
def get_user_column_permissions(uid):
"""根据用户获取其拥有的栏目权限详细信息"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"""
SELECT c.id, c.name, c.column_key, c.created_at
FROM user_column_permission ucp
JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
ORDER BY c.id
""",
(uid,),
)
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'] or '',
'column_key': r['column_key'] or '',
'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})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
params = {}
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/column-permissions',
params=params or None,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
def _set_user_column_permissions(cur, user_id, column_ids):

View File

@@ -23,8 +23,8 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -114,6 +114,18 @@ def init_db():
UNIQUE KEY uk_column_key (column_key)
)
""")
try:
cur.execute("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key")
except Exception:
pass
try:
cur.execute("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type")
except Exception:
pass
try:
cur.execute("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,

View File

@@ -637,8 +637,11 @@
// ========== 用户管理 ==========
var userPage = 1, userPageSize = 15;
var currentUserId = null;
var currentUserRole = 'admin';
var currentUserUsername = '';
var currentUserAdminPermissionKeys = {};
var currentUserAdminPermissionRoutes = {};
var adminsList = [];
function roleLabel(role) {
if (role === 'super_admin') return '超级管理员';
@@ -663,6 +666,7 @@
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
currentUserId = res.current_user_id || null;
currentUserRole = res.current_user_role || 'admin';
currentUserUsername = (res.current_user_username || '').trim();
adminsList = res.admins || [];
@@ -683,6 +687,7 @@
updateCreateFormByRole();
updateUserFilterByRole();
updateDedupeTotalDataAccess();
loadCurrentUserAdminPermissions();
})
.catch(function() {
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
@@ -722,6 +727,50 @@
if (usersPanel) usersPanel.classList.add('active');
}
}
function updateShopManageAccess() {
var tab = document.querySelector('.tab[data-tab="shop-manage"]');
var panel = document.getElementById('panel-shop-manage');
if (!tab || !panel) return;
var canUse = currentUserRole === 'super_admin' ||
!!currentUserAdminPermissionKeys['admin_shop_manage'] ||
!!currentUserAdminPermissionRoutes['shop-manage'];
tab.style.display = canUse ? '' : 'none';
panel.style.display = canUse ? '' : 'none';
if (!canUse && tab.classList.contains('active')) {
tab.classList.remove('active');
panel.classList.remove('active');
var usersTab = document.querySelector('.tab[data-tab="users"]');
var usersPanel = document.getElementById('panel-users');
if (usersTab) usersTab.classList.add('active');
if (usersPanel) usersPanel.classList.add('active');
}
}
function loadCurrentUserAdminPermissions() {
currentUserAdminPermissionKeys = {};
currentUserAdminPermissionRoutes = {};
if (!currentUserId || currentUserRole === 'super_admin') {
updateShopManageAccess();
return;
}
fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin')
.then(function(r) { return r.json(); })
.then(function(res) {
if (!res.success) {
updateShopManageAccess();
return;
}
(res.items || []).forEach(function(item) {
var columnKey = (item.column_key || '').trim();
var routePath = (item.route_path || '').trim();
if (columnKey) currentUserAdminPermissionKeys[columnKey] = true;
if (routePath) currentUserAdminPermissionRoutes[routePath] = true;
});
updateShopManageAccess();
})
.catch(function() {
updateShopManageAccess();
});
}
var allColumnsList = [];
function loadColumnsForPermission() {
fetch('/api/admin/columns')

View File

@@ -345,6 +345,29 @@ function removeSessionTaskFromStorage(taskId: number) {
}
}
function isTerminalStatus(status?: string | null) {
return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED'
}
function pruneTerminalSessionTasksByHistory(items: DeleteBrandResultItem[]) {
if (!items?.length) return false
const terminalTaskIds = new Set<number>()
items.forEach((item) => {
if (item.taskId && isTerminalStatus(item.taskStatus)) {
terminalTaskIds.add(item.taskId)
}
})
if (!terminalTaskIds.size) return false
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter((task) => !terminalTaskIds.has(task.taskId))
if (sessionTasks.value.length !== oldLen) {
persistSessionTasks()
return true
}
return false
}
const historySectionItems = computed(() =>
historyItems.value.filter(
@@ -681,7 +704,7 @@ function formatProgressPercent(item: DeleteBrandResultItem) {
function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' || status === 'FAILED'
return isTerminalStatus(status)
}
function getPollingTaskIds() {
@@ -1061,6 +1084,7 @@ async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
historyItems.value = normalizeDeleteBrandItems(response.items || [])
pruneTerminalSessionTasksByHistory(historyItems.value)
syncResultState()
// 关键:页面加载历史后,立刻拉取详情触发状态机

View File

@@ -129,8 +129,11 @@ const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const dispatchTimers = new Map<number, number>()
const scheduledDispatchInFlight = ref(false)
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalById(taskId) }), taskSnapshots.value, 'current'))
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalById(taskId) }), taskSnapshots.value, 'history'))
let scheduleHeartbeatTimer: number | null = null
let lastScheduledErrorAt = 0
let lastScheduledErrorKey = ''
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] })
@@ -173,7 +176,14 @@ function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(tas
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
function removePollingTask(taskId: number) { clearDispatchTimer(taskId); pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId); delete taskDetails.value[taskId]; savePollingIds(); saveTaskDetailsToStorage() }
function stopPollingTask(taskId: number, keepStatus = true) { pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId); if (!keepStatus) delete taskDetails.value[taskId]; savePollingIds(); saveTaskDetailsToStorage() }
function isTaskTerminalById(taskId?: number) { if (!taskId) return false; const status = taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status; return status === 'SUCCESS' || status === 'FAILED' }
function isTaskTerminalStatus(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' }
function isTaskTerminalById(taskId?: number) { if (!taskId) return false; const status = taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status; return isTaskTerminalStatus(status) }
function isTaskTerminalItem(item: ShopMatchHistoryItem) {
const taskId = normalizeTaskId(item.taskId)
if (taskId && isTaskTerminalById(taskId)) return true
if (isTaskTerminalStatus(item.taskStatus)) return true
return !!item.success
}
async function loadCandidates() { candidates.value = await listShopMatchCandidates() }
async function loadDashboard() { dashboard.value = await getShopMatchDashboard() }
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
@@ -201,33 +211,56 @@ function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQu
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const normalized = normalizeScheduleValue(value); const date = resolveScheduleDateTime(normalized); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], stage_index: stageIndex, final_stage: finalStage } } }
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = new Date(stage.scheduledAt).getTime(); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; queuePushResult.value = message; ElMessage.error(message) } finally { scheduledDispatchInFlight.value = false } }
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = new Date(stage.scheduledAt).getTime(); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } finally { scheduledDispatchInFlight.value = false } }
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = new Date(scheduledAt).getTime(); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removePollingTask(missingId); delete nextSnapshots[missingId] } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (status === 'SUCCESS' || status === 'FAILED') { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removePollingTask(missingId); delete nextSnapshots[missingId] } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4000 : 10000 }
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (status === 'SUCCESS' || status === 'FAILED') { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatTimeOnly(stage.scheduledAt) } if (item.scheduledAt) return formatTimeOnly(item.scheduledAt); return '' }
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload(item: ShopMatchHistoryItem) { return !!item.resultId && !!item.downloadUrl && resolvedTaskStatus(item) === 'SUCCESS' }
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && !!item.downloadUrl && (status === 'SUCCESS' || status === 'COMPLETED') }
async function downloadResult(item: ShopMatchHistoryItem) { if (!item.resultId) return; const api = getPywebviewApi(); if (!api?.save_file_from_url_new) { ElMessage.error('当前客户端未提供下载能力'); return } const url = getShopMatchResultDownloadUrl(item.resultId); const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`; const result = await api.save_file_from_url_new(url, filename); if (result.success) ElMessage.success(`已保存: ${result.path || filename}`); else if (result.error && result.error !== '用户取消') ElMessage.error(result.error) }
async function deleteTaskRecord(item: ShopMatchHistoryItem) { try { if (item.resultId) await deleteShopMatchHistory(item.resultId); else if (item.taskId) { await deleteShopMatchTask(item.taskId); removePollingTask(item.taskId) } await loadHistory(); await loadCandidates(); await loadDashboard() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '删除失败') } }
async function deleteTaskRecord(item: ShopMatchHistoryItem) {
const taskId = normalizeTaskId(item.taskId)
const resultId = Number(item.resultId || 0)
try {
if (taskId > 0) {
await deleteShopMatchTask(taskId)
removePollingTask(taskId)
historyItems.value = historyItems.value.filter((row) => normalizeTaskId(row.taskId) !== taskId)
delete taskSnapshots.value[taskId]
saveTaskSnapshotsToStorage()
} else if (resultId > 0) {
await deleteShopMatchHistory(resultId)
historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId)
} else {
throw new Error('缺少可删除的任务标识')
}
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }[value] || value || '—' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await createShopMatchTask([item], orderedCountryCodes.value, scheduleValues); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { scheduleDispatch(taskId, scheduleValues[0]); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await createShopMatchTask([item], orderedCountryCodes.value, scheduleValues); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { stopPolling(); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
</script>
<style scoped>

View File

@@ -7,24 +7,15 @@
<nav class="nav-tabs">
<div class="nav-tab-group">
<div
v-for="group in navGroups"
:key="group.label"
class="nav-dropdown"
:class="{ active: group.items.some(item => item.key === active) }"
>
<div v-for="group in navGroups" :key="group.label" class="nav-dropdown"
:class="{ active: group.items.some(item => item.key === active) }">
<button type="button" class="nav-dropdown-trigger">
<span>{{ group.label }}</span>
<span class="nav-dropdown-arrow"></span>
</button>
<div class="nav-dropdown-menu">
<a
v-for="item in group.items"
:key="item.key"
:href="item.href"
class="nav-dropdown-item"
:class="{ active: active === item.key }"
>
<a v-for="item in group.items" :key="item.key" :href="item.href" class="nav-dropdown-item"
:class="{ active: active === item.key }">
{{ item.label }}
</a>
</div>
@@ -58,7 +49,7 @@ const navGroups = [
items: [
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '匹配店铺', href: '/new_web_source/shop-match.html' },
{ key: 'shop-match', label: '定时跟价匹配', href: '/new_web_source/shop-match.html' },
],
},
] as const

View File

@@ -1,4 +1,4 @@
import {
import {
del,
get,
http,
@@ -506,7 +506,7 @@ export function submitDeleteBrandResult(
);
}
/** 商品风险解决:备选店铺与匹配(推队列由前端 pywebview 完成) */
/** 商品风险处理:备选店铺与匹配相关数据结构。 */
export interface ProductRiskCandidateVo {
id: number;
shop_name: string;
@@ -921,7 +921,7 @@ export function getJavaDownloadUrl(path: string) {
path.startsWith("http://") || path.startsWith("https://")
? path
: `${JAVA_API_PREFIX}${path}`;
// 核心修复:pywebview 的 save_file_from_url_new 需要完整带 schema 的 URL
// pywebview 的 save_file_from_url_new 需要完整带 schema 的 URL
if (
!raw.startsWith("http://") &&
!raw.startsWith("https://") &&
@@ -934,3 +934,4 @@ export function getJavaDownloadUrl(path: string) {
}
export { JAVA_API_PREFIX, http };