From e6021593eab781feeacf954720ca6859f0f99755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 11 Sep 2026 17:11:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8/=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7):=20=E5=85=A8=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=89=A9=E5=B1=95+=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=A9=BF=E8=B6=8A+=E5=BF=99=E7=AD=89+=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminApiGuardFilter 兜底扩展到 /api/collect-data、/api/price-track:无需鉴权的 工具接口纳入 JWT/内部令牌校验(原匿名可达即越权读写他人数据) - pricetrack asinFiles 改为仅允许上传临时目录内文件(canonical 前缀校验), 修复请求路径直接 new File 可读服务器任意 csv/xlsx 的穿越 - dedupe 删除导入逐行 REQUIRES_NEW 事务改 500 条一批 IN 删除,50 万行导入 由 50 万个事务收敛为千级 - 前端记住密码 XOR 硬编码密钥改 WebCrypto AES-GCM(密钥随机生成独立存储), 登录流程接口改 async 并保证自动登录恢复时序 - 任务进度轮询失败按指数退避(原固定 5s 无限撞);下载进度终态条目 2 分钟 自动清理(原永久堆积);AmazonConsolePage statusTimer 卸载清理 --- .../aiimage/config/AdminApiGuardFilter.java | 22 ++++- .../service/DedupeTotalDataService.java | 30 +++++-- .../file/service/LocalFileStorageService.java | 5 ++ .../service/PriceTrackTaskService.java | 24 ++++- .../src/pages/amazon/AmazonConsolePage.vue | 9 +- .../src/pages/login/DesktopLoginPage.vue | 89 ++++++++++++++----- .../shared/composables/useTaskProgressLoop.ts | 7 +- .../src/shared/utils/download-progress.ts | 28 +++++- 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java index 5ee7fc98..661f9838 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -51,6 +51,16 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { /** 调试端点前缀:无方法级鉴权的诊断/运维入口,同样纳入兜底(2026-09-11)。 */ private static final String DEBUG_PREFIX = "/debug"; + /** + * 用户态工具接口前缀:controller 无方法级鉴权(归属由请求参数 user_id 判定), + * 匿名可达即越权读写他人数据。纳入兜底要求 JWT 或可信内部令牌(2026-09-12)。 + * 桌面 Python 直连调用已同步携带 X-Internal-Token。 + */ + private static final String[] USER_TOOL_PREFIXES = { + "/api/collect-data", + "/api/price-track", + }; + private final AdminAuthSupport adminAuthSupport; private final ObjectMapper objectMapper; @@ -110,9 +120,17 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { chain.doFilter(request, response); } - /** 命中受保护前缀(/api/admin、/debug 及其子路径)才进入鉴权,其余请求直接放行。 */ + /** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */ private static boolean isGuarded(String uri) { - return matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX); + if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) { + return true; + } + for (String prefix : USER_TOOL_PREFIXES) { + if (matchesPrefix(uri, prefix)) { + return true; + } + } + return false; } private static boolean matchesPrefix(String uri, String prefix) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index fe829bea..db2b0cb4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -867,6 +867,7 @@ public class DedupeTotalDataService { } Set seenInFile = new HashSet<>(); + List pendingDeletes = new ArrayList<>(); int totalRows = Math.max(sheet.getLastRowNum(), 0); if (maxImportRows > 0 && totalRows > maxImportRows) { throw new BusinessException("导入行数超过上限: " + maxImportRows); @@ -919,12 +920,11 @@ public class DedupeTotalDataService { continue; } - int deletedThisRow = newRequiresNewTemplate().execute( - status -> deleteByDataValue(dataValue, scope, groupId)); - if (deletedThisRow > 0) { - deletedCount += deletedThisRow; - } else { - skippedCount++; + // 批量删除:攒批 + IN 一次删,避免 50 万行 = 50 万个 REQUIRES_NEW 事务的 N+1 + pendingDeletes.add(dataValue); + if (pendingDeletes.size() >= DELETE_BATCH_SIZE) { + deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId); + pendingDeletes = new ArrayList<>(); } if (progress != null) { progress.setProcessedRows(rowNum); @@ -933,6 +933,9 @@ public class DedupeTotalDataService { progress.setSkippedCount(skippedCount); } } + if (!pendingDeletes.isEmpty()) { + deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId); + } DedupeTotalDataImportVo vo = new DedupeTotalDataImportVo(); vo.setTotalRows(totalRows); @@ -1016,6 +1019,21 @@ public class DedupeTotalDataService { return dedupeTotalDataMapper.delete(query); } + /** 批量删除大小:单事务 IN 删除的阈值,兼顾锁窗口与事务日志。 */ + private static final int DELETE_BATCH_SIZE = 500; + + /** 整批一个独立事务按 IN 一次删除;返回实际删除行数(计入 deletedCount,不再额外计 skipped)。 */ + private int deleteBatchByDataValues(List dataValues, AccessScope scope, Long groupId) { + List batch = List.copyOf(dataValues); + Integer deleted = newRequiresNewTemplate().execute(status -> { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .in(DedupeTotalDataEntity::getDataValue, batch); + applyGroupScope(query, scope, groupId); + return dedupeTotalDataMapper.delete(query); + }); + return deleted == null ? 0 : deleted; + } + private AccessScope resolveAccessScope(Long operatorId) { AdminUserEntity operator = getOperator(operatorId); if (isSuperAdmin(operator)) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java index a5c39ba8..83f87a82 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java @@ -33,6 +33,11 @@ public class LocalFileStorageService { private final StorageProperties storageProperties; + /** 临时目录标准路径:供调用方做"文件必须落在上传临时目录内"的穿越校验。 */ + public File localTempRoot() { + return FileUtil.file(storageProperties.getLocalTempDir()); + } + /** 源文件确定路径索引:saveTempFile 写入后登记,查找优先命中,兜底目录枚举。 */ private final Map sourceFileIndex = new LinkedHashMap<>(16, 0.75f, true); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index c539f5d0..f1645dd7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -42,6 +42,7 @@ import org.springframework.transaction.annotation.Transactional; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; +import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -1462,15 +1463,16 @@ public class PriceTrackTaskService { if (rawPath == null || rawPath.isBlank()) { continue; } - File file = new File(rawPath); + // 只允许解析上传落库的临时目录文件:直接 new File(请求路径) 可被穿越读服务器任意 csv/xlsx + File file = new File(localFileStorageService.localTempRoot().getAbsolutePath(), rawPath); if (!file.isFile()) { - // 直接路径不可读时兜底:按上传返回的 fileKey 反查服务器本地临时目录 + // 传入的是 fileKey/索引键时按上传索引反查临时目录 File resolved = localFileStorageService.findLocalSourceFile(rawPath); if (resolved != null) { file = resolved; } } - if (!file.isFile()) { + if (!file.isFile() || !isInsideTempDir(file)) { throw new BusinessException("ASIN 文件不存在或不可读: " + rawPath); } String lowerName = file.getName().toLowerCase(Locale.ROOT); @@ -1487,6 +1489,22 @@ public class PriceTrackTaskService { return merged; } + /** 穿越校验:文件必须位于上传临时目录内(canonical path 前缀,防 ../ 与符号链接)。 */ + private boolean isInsideTempDir(File file) { + try { + File root = localFileStorageService.localTempRoot(); + if (!root.exists()) { + return false; + } + String rootPath = root.getCanonicalPath(); + String filePath = file.getCanonicalPath(); + return filePath.equals(rootPath) || filePath.startsWith(rootPath + File.separator); + } catch (IOException e) { + log.warn("[price-track] ASIN 文件路径规范化失败,拒绝解析: {}", file, e); + return false; + } + } + private Map>> parseWorkbookAsinRows(File file, List countryCodes) { Map>> out = new LinkedHashMap<>(); AtomicReference> headerIndexHolder = new AtomicReference<>(Map.of()); diff --git a/frontend-vue/src/pages/amazon/AmazonConsolePage.vue b/frontend-vue/src/pages/amazon/AmazonConsolePage.vue index 5b4a8c66..40785a55 100644 --- a/frontend-vue/src/pages/amazon/AmazonConsolePage.vue +++ b/frontend-vue/src/pages/amazon/AmazonConsolePage.vue @@ -120,7 +120,7 @@