提交bug修复 后台权限管理

This commit is contained in:
super
2026-04-11 22:25:14 +08:00
parent b400b494ad
commit 7589b4e418
36 changed files with 956 additions and 359 deletions

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'
);