From 64e4c1953171f38df091441c666579aa2d0b900d 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, 4 Sep 2026 10:21:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Java=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=BC=BA=E5=A4=B1=E8=83=BD=E5=8A=9B(?= =?UTF-8?q?=E8=BD=AF=E4=BB=B6=E7=89=88=E6=9C=AC/=E5=9B=BE=E7=94=9F?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E7=AE=A1=E7=90=86/=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8)=E4=B8=8E=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Flask 后台仅本地实现、Java 缺失的模块落为原生端点(替代 15124 本地面, 向"单一 Java 后台、可退役 15124"收敛): - softwareversion: GET /api/admin/versions、POST /api/admin/version(web_config + 公开桶 nanri-image/versions/ 直链,桌面端"检测更新"同表兼容) - imagevideo 管理端: GET /api/admin/image-video-tasks、/{taskId}、 POST /download-zip(近3天 IMAGE_VIDEO_WORKFLOW,语义对齐 Flask) - shopdatacrawl 管理端: GET /api/admin/shop-data-crawl-tasks(按店分组取最新结果, 窗口函数)、POST /download-zip(流式 + X-Archive-*-Count) - 权限: 菜单同级拖拽排序 POST /column/reorder(事务内按序重排兄弟 sort_order); PermissionMenuCreate/UpdateRequest 补 snake_case 别名兼容旧控制台 body 以上端点均 requireAdminOrInternal 鉴权;含 PermissionMenuService 早前已改动的 hasAnyAdminMenu 等菜单权限逻辑(本迁移一环)。 --- .../AdminImageVideoTaskController.java | 189 ++++++++ .../mapper/ImageVideoAdminTaskMapper.java | 117 +++++ .../service/ImageVideoAdminTaskService.java | 453 ++++++++++++++++++ .../controller/PermissionMenuController.java | 30 ++ .../dto/PermissionMenuCreateRequest.java | 4 + .../dto/PermissionMenuUpdateRequest.java | 4 + .../service/PermissionMenuService.java | 73 +++ .../AdminShopDataCrawlTasksController.java | 324 +++++++++++++ .../mapper/ShopDataCrawlAdminTasksMapper.java | 185 +++++++ .../ShopDataCrawlAdminFileJobBriefDto.java | 15 + .../dto/ShopDataCrawlAdminGroupLabelDto.java | 13 + .../model/dto/ShopDataCrawlAdminGroupRow.java | 15 + .../model/dto/ShopDataCrawlAdminRow.java | 41 ++ .../dto/ShopDataCrawlDownloadRowDto.java | 18 + .../ShopDataCrawlAdminTasksService.java | 333 +++++++++++++ .../SoftwareVersionAdminController.java | 56 +++ .../mapper/SoftwareVersionMapper.java | 9 + .../model/entity/SoftwareVersionEntity.java | 29 ++ .../service/SoftwareVersionService.java | 149 ++++++ 19 files changed, 2057 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/AdminImageVideoTaskController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAdminTaskMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAdminTaskService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/SoftwareVersionAdminController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/mapper/SoftwareVersionMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/model/entity/SoftwareVersionEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/AdminImageVideoTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/AdminImageVideoTaskController.java new file mode 100644 index 00000000..6950d75a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/AdminImageVideoTaskController.java @@ -0,0 +1,189 @@ +package com.nanri.aiimage.modules.imagevideo.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.imagevideo.service.ImageVideoAdminTaskService; +import com.nanri.aiimage.modules.imagevideo.service.ImageVideoAdminTaskService.VideoSelection; +import com.nanri.aiimage.modules.imagevideo.service.ImageVideoAdminTaskService.ZipBundle; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestBody; +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.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 图生视频任务管理端点(替代 Flask admin_api.py 的 /image-video-tasks* 本地实现)。 + * + *

路径与入参为管理后台前端契约,字段名保持 snake_case。

+ */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/image-video-tasks") +@Tag(name = "图生视频任务管理", description = "图生视频/视频复刻工作流任务的查询与批量打包下载") +public class AdminImageVideoTaskController { + + private static final DateTimeFormatter[] DATETIME_PATTERNS = { + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + }; + private static final DateTimeFormatter ZIP_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final int MAX_ZIP_SELECTIONS = 100; + private static final String X_ARCHIVE_FILE_COUNT = "X-Archive-File-Count"; + private static final String X_ARCHIVE_ERROR_COUNT = "X-Archive-Error-Count"; + + private final ImageVideoAdminTaskService adminTaskService; + private final AdminAuthSupport adminAuthSupport; + + @GetMapping + @Operation(summary = "分页查询图生视频任务", description = "管理端按用户/任务状态/提交时间等条件分页查询近 3 天的工作流任务") + public ApiResponse> page( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(name = "page_size", defaultValue = "20") Integer pageSize, + @RequestParam(name = "user_id", required = false) Long userId, + @RequestParam(required = false) String username, + @RequestParam(required = false) String status, + @RequestParam(name = "coze_execute_id", required = false) String executeId, + @RequestParam(name = "submitted_from", required = false) String submittedFrom, + @RequestParam(name = "submitted_to", required = false) String submittedTo, + HttpServletRequest request) { + adminAuthSupport.requireAdminOrInternal(request); + int safePage = Math.max(1, page == null ? 1 : page); + int safePageSize = Math.min(100, Math.max(10, pageSize == null ? 20 : pageSize)); + String cleanUsername = username == null ? null : username.trim(); + String cleanStatus = status == null ? null : status.trim().toUpperCase(); + String cleanExecuteId = executeId == null ? null : executeId.trim(); + LocalDateTime from = parseAdminDatetimeParam("submitted_from", submittedFrom); + LocalDateTime to = parseAdminDatetimeParam("submitted_to", submittedTo); + log.info("[image-video] 管理端查询图生视频任务 page={} pageSize={} userId={} username={} status={}", + safePage, safePageSize, userId, cleanUsername, cleanStatus); + return ApiResponse.success(adminTaskService.page(userId, cleanUsername, cleanStatus, + cleanExecuteId, from, to, safePage, safePageSize)); + } + + @GetMapping("/{taskId}") + @Operation(summary = "查询图生视频任务详情", description = "返回单个任务详情(含脱敏后的请求参数与结果 JSON)") + public ApiResponse> detail(@PathVariable Long taskId, HttpServletRequest request) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success(adminTaskService.detail(taskId)); + } + + @PostMapping("/download-zip") + @Operation(summary = "批量打包下载视频", description = "按任务+视频下标批量打包 zip;部分失败通过响应头 X-Archive-Error-Count 提示") + public ResponseEntity downloadZip(@RequestBody(required = false) Map body, + HttpServletRequest request) { + adminAuthSupport.requireAdminOrInternal(request); + List selections = parseSelections(body); + ZipBundle bundle = adminTaskService.buildZip(selections); + String filename = "video-tasks-" + LocalDateTime.now().format(ZIP_FILENAME_FORMATTER) + ".zip"; + StreamingResponseBody stream = outputStream -> writeZipFile(bundle.file(), outputStream); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) + .header(X_ARCHIVE_FILE_COUNT, String.valueOf(bundle.fileCount())) + .header(X_ARCHIVE_ERROR_COUNT, String.valueOf(bundle.errorCount())) + .body(stream); + } + + private void writeZipFile(Path file, OutputStream output) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + input.transferTo(output); + } catch (Exception ex) { + log.warn("[image-video] 管理端 zip 回传中断 path={} error={}", file, ex.getMessage()); + throw ex; + } finally { + try { + Files.deleteIfExists(file); + } catch (IOException ex) { + log.warn("[image-video] 管理端 zip 临时文件清理失败 path={} error={}", file, ex.getMessage()); + } + } + } + + /** 解析下载选择参数,校验失败抛业务异常(对齐 Flask 文案)。 */ + private List parseSelections(Map body) { + Object rawItems = body == null ? null : body.get("items"); + if (!(rawItems instanceof List list) || list.isEmpty()) { + throw new BusinessException(400, "请至少选择一个视频"); + } + if (list.size() > MAX_ZIP_SELECTIONS) { + throw new BusinessException(400, "单次最多打包 100 个视频"); + } + Set seen = new LinkedHashSet<>(); + List selections = new ArrayList<>(list.size()); + for (Object rawItem : list) { + try { + if (!(rawItem instanceof Map item)) { + throw new NumberFormatException(); + } + long taskId = toLong(item.get("task_id")); + int videoIndex = toLong(item.get("video_index")).intValue(); + if (taskId <= 0 || videoIndex < 0) { + throw new NumberFormatException(); + } + String key = taskId + ":" + videoIndex; + if (seen.add(key)) { + selections.add(new VideoSelection(taskId, videoIndex)); + } + } catch (NumberFormatException | NullPointerException ex) { + throw new BusinessException(400, "视频选择参数无效"); + } + } + return selections; + } + + private Long toLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + return Long.valueOf(String.valueOf(value).trim()); + } + + /** 解析管理页时间筛选参数(datetime-local 或 ISO),解析失败抛 400 中文提示。 */ + private LocalDateTime parseAdminDatetimeParam(String name, String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return null; + } + String text = rawValue.trim(); + if (text.endsWith("Z") || text.endsWith("z")) { + text = text.substring(0, text.length() - 1); + } + for (DateTimeFormatter formatter : DATETIME_PATTERNS) { + try { + return LocalDateTime.parse(text, formatter); + } catch (DateTimeParseException ignored) { + // 尝试下一种格式 + } + } + throw new BusinessException(400, name + " 时间格式无效"); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAdminTaskMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAdminTaskMapper.java new file mode 100644 index 00000000..5802b5e4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/mapper/ImageVideoAdminTaskMapper.java @@ -0,0 +1,117 @@ +package com.nanri.aiimage.modules.imagevideo.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 图生视频任务管理端查询(替代 Flask admin_api.py 中 image-video-tasks 本地 SQL)。 + */ +@Mapper +public interface ImageVideoAdminTaskMapper { + + @Select(""" + + """) + List> selectAdminTaskPage( + @Param("userId") Long userId, + @Param("username") String username, + @Param("status") String status, + @Param("executeId") String executeId, + @Param("submittedFrom") LocalDateTime submittedFrom, + @Param("submittedTo") LocalDateTime submittedTo, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + Long countAdminTaskPage( + @Param("userId") Long userId, + @Param("username") String username, + @Param("status") String status, + @Param("executeId") String executeId, + @Param("submittedFrom") LocalDateTime submittedFrom, + @Param("submittedTo") LocalDateTime submittedTo); + + @Select(""" + + """) + List> selectAdminZipRows(@Param("taskIds") List taskIds); + + @Select(""" + SELECT t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json, + t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status, + t.archive_error, t.archive_attempt_count, + DATE_FORMAT(t.archived_at, '%Y-%m-%d %H:%i:%s') AS archived_at, + t.error_message, t.coze_execute_id, t.coze_status, + DATE_FORMAT(t.submitted_at, '%Y-%m-%d %H:%i:%s') AS submitted_at, + DATE_FORMAT(t.completed_at, '%Y-%m-%d %H:%i:%s') AS completed_at, + u.username, + COALESCE(( + SELECT GROUP_CONCAT(DISTINCT g.group_name ORDER BY g.id SEPARATOR '、') + FROM biz_shop_manage_group g + LEFT JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id + WHERE g.created_by_id = t.user_id OR g.user_id = t.user_id OR gm.user_id = t.user_id + ), '') AS group_name + FROM biz_image_video_async_task t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW' + AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) + AND t.id = #{taskId} + LIMIT 1 + """) + Map selectAdminTaskDetail(@Param("taskId") Long taskId); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAdminTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAdminTaskService.java new file mode 100644 index 00000000..1aa56467 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAdminTaskService.java @@ -0,0 +1,453 @@ +package com.nanri.aiimage.modules.imagevideo.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAdminTaskMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.URI; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 图生视频任务管理端组装/打包逻辑(替代 Flask admin_api.py 的 image-video-tasks 实现)。 + * + *

对外 JSON 一律输出 snake_case 字段名,与 Flask 管理页契约一致。

+ */ +@Slf4j +@Service +public class ImageVideoAdminTaskService { + + private static final Pattern VIDEO_EXTENSION_PATTERN = + Pattern.compile("\\.([a-z0-9]{2,5})(?:[?#]|$)", Pattern.CASE_INSENSITIVE); + private static final String FILE_NAME_PREFIX = "video-tasks-"; + private static final DateTimeFormatter ZIP_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final Set SECRET_KEYS = Set.of( + "api_key", "apikey", "token", "coze_token", "cozetoken", "t8_key", "t8key", + "t8_video_key", "t8videokey", "t8star_key", "t8starkey", + "ai_conductor_key", "aiconductorkey", "copy_api_key", "copyapikey", + "voice_api_key", "voiceapikey" + ); + private static final Set LOCAL_HOSTNAMES = Set.of( + "localhost", "localhost.localdomain", "metadata.google.internal", + "metadata.azure.internal", "169.254.169.254" + ); + + private static final int HTTP_CONNECT_TIMEOUT_MILLIS = 10_000; + private static final int HTTP_READ_TIMEOUT_MILLIS = 120_000; + private static final int STREAM_CHUNK_BYTES = 1024 * 1024; + private static final String DOWNLOAD_ERRORS_ENTRY = "download-errors.txt"; + + private final ImageVideoAdminTaskMapper adminTaskMapper; + private final ObjectMapper objectMapper; + + public ImageVideoAdminTaskService(ImageVideoAdminTaskMapper adminTaskMapper, ObjectMapper objectMapper) { + this.adminTaskMapper = adminTaskMapper; + this.objectMapper = objectMapper; + } + + /** + * 分页查询图生视频工作流任务,返回 {items, total, page, page_size}。 + */ + public Map page(Long userId, String username, String status, String executeId, + LocalDateTime submittedFrom, LocalDateTime submittedTo, + int page, int pageSize) { + int offset = (page - 1) * pageSize; + List> rows = adminTaskMapper.selectAdminTaskPage( + userId, username, status, executeId, submittedFrom, submittedTo, pageSize, offset); + Long total = adminTaskMapper.countAdminTaskPage( + userId, username, status, executeId, submittedFrom, submittedTo); + List> items = new ArrayList<>(rows.size()); + for (Map row : rows) { + items.add(toListItem(row, false)); + } + Map result = new LinkedHashMap<>(); + result.put("items", items); + result.put("total", total == null ? 0L : total); + result.put("page", (long) page); + result.put("page_size", (long) pageSize); + return result; + } + + /** + * 单任务详情(含 request/submit_response/result 原始 JSON,request 脱敏)。 + */ + public Map detail(Long taskId) { + Map row = adminTaskMapper.selectAdminTaskDetail(taskId); + if (row == null) { + throw new BusinessException(404, "视频任务不存在或无权访问"); + } + return Map.of("item", toListItem(row, true)); + } + + /** + * 批量打包下载选中的视频。逐文件远程拉取写入 zip,部分失败时记录错误清单文件。 + */ + public ZipBundle buildZip(List selections) { + List taskIds = selections.stream() + .map(VideoSelection::taskId) + .distinct() + .sorted() + .collect(Collectors.toList()); + Map> tasks = new HashMap<>(); + for (Map row : adminTaskMapper.selectAdminZipRows(taskIds)) { + if (row.get("id") instanceof Number id) { + tasks.put(id.longValue(), row); + } + } + + Path zipFile = null; + List errors = new ArrayList<>(); + int fileCount = 0; + try { + zipFile = Files.createTempFile("image-video-tasks-", ".zip"); + try (ZipOutputStream zip = new ZipOutputStream( + new BufferedOutputStream(Files.newOutputStream(zipFile), STREAM_CHUNK_BYTES))) { + for (VideoSelection selection : selections) { + Map row = tasks.get(selection.taskId()); + List> videos = row == null ? List.of() : buildVideos(row); + String url = ""; + if (selection.videoIndex() < videos.size()) { + Object displayUrl = videos.get(selection.videoIndex()).get("display_url"); + url = displayUrl == null ? "" : String.valueOf(displayUrl); + } + if (url.isBlank()) { + errors.add(String.format("task-%d-video-%d: 视频地址不存在", + selection.taskId(), selection.videoIndex() + 1)); + continue; + } + String filename = "task-" + selection.taskId() + "-video-" + (selection.videoIndex() + 1) + + "." + detectExtension(url); + if (isInternalUrl(url)) { + throw new BusinessException(400, "拒绝下载内网/本机地址的视频"); + } + HttpURLConnection connection = null; + try { + connection = openRemoteConnection(url); + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IOException("HTTP " + status); + } + zip.putNextEntry(new ZipEntry(filename)); + try (InputStream input = connection.getInputStream()) { + byte[] buffer = new byte[STREAM_CHUNK_BYTES]; + int read; + while ((read = input.read(buffer)) != -1) { + zip.write(buffer, 0, read); + } + } + zip.closeEntry(); + fileCount++; + } catch (Exception ex) { + errors.add(filename + ": 下载失败 (" + messageOf(ex) + ")"); + log.warn("[image-video] 管理端批量打包单文件失败 filename={} error={}", + filename, messageOf(ex)); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + if (!errors.isEmpty()) { + zip.putNextEntry(new ZipEntry(DOWNLOAD_ERRORS_ENTRY)); + zip.write(String.join("\n", errors).getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + log.info("[image-video] 管理端批量打包完成 文件数={} 失败数={} 选择数={}", + fileCount, errors.size(), selections.size()); + return new ZipBundle(zipFile, fileCount, errors.size()); + } catch (Exception ex) { + deleteQuietly(zipFile); + throw ex instanceof BusinessException business + ? business + : new BusinessException("批量打包失败: " + messageOf(ex), ex); + } + } + + private HttpURLConnection openRemoteConnection(String url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + connection.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(HTTP_READ_TIMEOUT_MILLIS); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", "aiimage-image-video-admin/1.0"); + return connection; + } + + private void deleteQuietly(Path path) { + if (path != null) { + try { + Files.deleteIfExists(path); + } catch (IOException ex) { + log.warn("[image-video] 管理端打包临时文件清理失败 path={} error={}", path, ex.getMessage()); + } + } + } + + private String messageOf(Exception ex) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message; + } + + private String detectExtension(String url) { + Matcher matcher = VIDEO_EXTENSION_PATTERN.matcher(url); + return matcher.find() ? matcher.group(1).toLowerCase(Locale.ROOT) : "mp4"; + } + + private Map toListItem(Map row, boolean includeJson) { + List> videos = buildVideos(row); + Map item = new LinkedHashMap<>(); + item.put("task_id", row.get("id")); + item.put("user_id", row.get("user_id")); + item.put("username", orEmpty(row.get("username"))); + item.put("group_name", orEmpty(row.get("group_name"))); + item.put("mode", modeOf(row.get("request_json"))); + item.put("status", orEmpty(row.get("status"))); + item.put("coze_status", orEmpty(row.get("coze_status"))); + item.put("coze_execute_id", orEmpty(row.get("coze_execute_id"))); + item.put("videos", videos); + item.put("video_url", videos.isEmpty() ? "" : videos.get(0).get("display_url")); + item.put("debug_url", orEmpty(row.get("debug_url"))); + item.put("archive_status", orEmpty(row.get("archive_status"))); + item.put("archive_error", orEmpty(row.get("archive_error"))); + item.put("archive_attempt_count", numberOrDefault(row.get("archive_attempt_count"))); + item.put("submitted_at", orEmpty(row.get("submitted_at"))); + item.put("completed_at", orEmpty(row.get("completed_at"))); + if (includeJson) { + item.put("error_message", orEmpty(row.get("error_message"))); + item.put("archived_at", orEmpty(row.get("archived_at"))); + Object request = parseJsonLoose(stringOf(row.get("request_json")), new LinkedHashMap()); + item.put("request", maskSecrets(request)); + item.put("submit_response", parseJsonLoose(stringOf(row.get("submit_response_json")), + new LinkedHashMap())); + item.put("result", parseJsonLoose(stringOf(row.get("result_json")), + new LinkedHashMap())); + } + return item; + } + + /** + * 拼装视频数组:以 video_urls_json 顺序为基准,从 archived_videos_json 补齐归档信息。 + */ + private List> buildVideos(Map row) { + List originals = parseStringList(row.get("video_urls_json")); + List> archived = parseArchivedList(row.get("archived_videos_json")); + Map> archivedBySource = new LinkedHashMap<>(); + for (Map stored : archived) { + archivedBySource.put(String.valueOf(stored.getOrDefault("sourceUrl", "")), stored); + } + List> items = new ArrayList<>(originals.size()); + for (String sourceUrl : originals) { + Map stored = archivedBySource.getOrDefault(sourceUrl, Map.of()); + Map video = new LinkedHashMap<>(); + video.put("source_url", sourceUrl); + video.put("archived_url", stored.getOrDefault("url", "")); + video.put("object_key", stored.getOrDefault("objectKey", "")); + video.put("archive_status", stored.getOrDefault("status", "")); + video.put("archive_error", stored.getOrDefault("error", "")); + String displayUrl = orEmpty(stored.get("url")); + video.put("display_url", displayUrl.isBlank() ? sourceUrl : displayUrl); + items.add(video); + } + return items; + } + + /** 从 request_json 中取 parameters.video_info.mode:1 图生视频、2 视频复刻,其余未知。 */ + private String modeOf(Object requestJson) { + Object parsed = parseJsonLoose(stringOf(requestJson), new LinkedHashMap()); + String mode = ""; + try { + Object parameters = childOf(parsed, "parameters"); + Object videoInfo = childOf(parameters, "video_info"); + Object rawMode = childOf(videoInfo, "mode"); + mode = rawMode == null ? "" : String.valueOf(rawMode).trim(); + } catch (Exception ignored) { + // 结构不完整按未知模式处理 + } + if ("1".equals(mode)) { + return "图生视频"; + } + if ("2".equals(mode)) { + return "视频复刻"; + } + return "未知"; + } + + private Object childOf(Object value, String name) { + return value instanceof Map map ? map.get(name) : null; + } + + /** 递归把配置密钥类的值替换为星号(与 Flask _mask_image_video_secrets 一致)。 */ + @SuppressWarnings("unchecked") + private Object maskSecrets(Object value) { + if (value instanceof Map map) { + Map masked = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object child = entry.getValue(); + String canonical = key.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + boolean secret = SECRET_KEYS.stream().anyMatch(item -> item.toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]", "").equals(canonical)); + if (secret && child != null && !String.valueOf(child).isBlank()) { + masked.put(key, "******"); + } else { + masked.put(key, maskSecrets(child)); + } + } + return masked; + } + if (value instanceof List list) { + List masked = new ArrayList<>(list.size()); + for (Object child : list) { + masked.add(maskSecrets(child)); + } + return masked; + } + return value; + } + + private Object parseJsonLoose(String json, Object fallback) { + if (json == null || json.isBlank()) { + return fallback; + } + try { + return objectMapper.readValue(json, new TypeReference() { + }); + } catch (Exception ex) { + return json; + } + } + + private List parseStringList(Object raw) { + String json = stringOf(raw); + if (json == null || json.isBlank()) { + return new ArrayList<>(); + } + try { + List parsed = objectMapper.readValue(json, new TypeReference>() { + }); + return parsed == null ? new ArrayList<>() : parsed; + } catch (Exception ex) { + return new ArrayList<>(); + } + } + + private List> parseArchivedList(Object raw) { + String json = stringOf(raw); + if (json == null || json.isBlank()) { + return new ArrayList<>(); + } + try { + List> parsed = objectMapper.readValue(json, new TypeReference>>() { + }); + return parsed == null ? new ArrayList<>() : parsed; + } catch (Exception ex) { + return new ArrayList<>(); + } + } + + /** + * 内网/本机/保留地址判断(对齐 Flask utils/ssrf.py 的 is_internal_url),解析失败按不可信处理。 + */ + private boolean isInternalUrl(String url) { + try { + URI uri = URI.create(url); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return true; + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return true; + } + String normalized = host.toLowerCase(Locale.ROOT); + while (normalized.endsWith(".")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (LOCAL_HOSTNAMES.contains(normalized)) { + return true; + } + InetAddress[] addresses = InetAddress.getAllByName(normalized); + if (addresses.length == 0) { + return true; + } + for (InetAddress address : addresses) { + if (isBlockedAddress(address)) { + return true; + } + } + return false; + } catch (Exception ex) { + return true; + } + } + + private boolean isBlockedAddress(InetAddress address) { + if (address.isAnyLocalAddress() || address.isLoopbackAddress() + || address.isLinkLocalAddress() || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return true; + } + byte[] raw = address.getAddress(); + if (raw == null) { + return true; + } + if (raw.length != 4) { + // IPv6 其余保留段:fc00::/7 内网唯一本地地址 + int first = raw[0] & 0xff; + return first == 0xfc || first == 0xfd; + } + int first = raw[0] & 0xff; + int second = raw[1] & 0xff; + int third = raw[2] & 0xff; + if (first == 100) { + return second >= 64 && second <= 127; // 100.64.0.0/10 CGNAT + } + if (first == 192 && second == 0 && third == 0) { + return true; // 192.0.0.0/24 + } + return first == 198 && (second == 18 || second == 19); // 198.18.0.0/15 + } + + private String orEmpty(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private String stringOf(Object value) { + return value == null ? null : String.valueOf(value); + } + + private int numberOrDefault(Object value) { + return value instanceof Number number ? number.intValue() : 0; + } + + /** 待打包的视频选择:taskId + 视频下标(0 起)。 */ + public record VideoSelection(Long taskId, int videoIndex) { + } + + /** 打包结果:临时 zip 文件路径、成功文件数与失败数。 */ + public record ZipBundle(Path file, int fileCount, int errorCount) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java index 0cc0a3f1..45bc9f04 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java @@ -31,7 +31,11 @@ import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.Map; + +import com.nanri.aiimage.common.exception.BusinessException; @RestController @RequiredArgsConstructor @@ -80,6 +84,32 @@ public class PermissionMenuController { return ApiResponse.success("删除成功", null); } + @PostMapping("/column/reorder") + @Operation(summary = "同级菜单拖拽排序") + public ApiResponse reorderColumns(HttpServletRequest httpRequest, + @RequestBody(required = false) Map body) { + requireAdmin(httpRequest); + Object raw = body == null ? null + : (body.get("ordered_ids") != null ? body.get("ordered_ids") : body.get("orderedIds")); + if (!(raw instanceof List list)) { + throw new BusinessException("排序参数无效"); + } + List ids = new ArrayList<>(list.size()); + for (Object item : list) { + if (item instanceof Number number) { + ids.add(number.longValue()); + } else if (item instanceof String text) { + try { + ids.add(Long.parseLong(text.trim())); + } catch (NumberFormatException ignore) { + // 忽略非数字项 + } + } + } + permissionMenuService.reorder(ids); + return ApiResponse.success("排序已保存", null); + } + @GetMapping("/permission-users/{userId}/columns") @Operation(summary = "查询用户直接授权菜单 ID 列表", description = "columnIds 仅表示数据库保存的直接授权,所有角色都不包含递归或虚拟权限") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java index fea96350..10f86271 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java @@ -11,6 +11,7 @@ public class PermissionMenuCreateRequest { private String name; @NotBlank(message = "菜单标识不能为空") + @JsonAlias("column_key") private String columnKey; /** 直接父菜单;null 表示根菜单。 */ @@ -18,10 +19,13 @@ public class PermissionMenuCreateRequest { private Long parentId; @NotBlank(message = "菜单类型不能为空") + @JsonAlias("menu_type") private String menuType; @NotBlank(message = "菜单路由不能为空") + @JsonAlias("route_path") private String routePath; + @JsonAlias("sort_order") private Integer sortOrder; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java index 89782930..192a57c7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java @@ -11,6 +11,7 @@ public class PermissionMenuUpdateRequest { private String name; @NotBlank(message = "菜单标识不能为空") + @JsonAlias("column_key") private String columnKey; /** 直接父菜单;null 表示根菜单。 */ @@ -18,10 +19,13 @@ public class PermissionMenuUpdateRequest { private Long parentId; @NotBlank(message = "菜单类型不能为空") + @JsonAlias("menu_type") private String menuType; @NotBlank(message = "菜单路由不能为空") + @JsonAlias("route_path") private String routePath; + @JsonAlias("sort_order") private Integer sortOrder; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 1a841b7b..28e25f80 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -1,6 +1,7 @@ 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; @@ -149,6 +150,54 @@ public class PermissionMenuService { permissionMenuMapper.deleteById(entity.getId()); } + /** + * 同级菜单拖拽重排:orderedIds 为同一父级下按新顺序排列的菜单 ID 列表。 + * 不在列表中的兄弟节点保持原相对顺序追加在末尾,重排块锚定在原最小 sort_order,避免跨级穿插。 + */ + @Transactional + public void reorder(List orderedIds) { + if (orderedIds == null || orderedIds.isEmpty()) { + return; + } + List ids = new ArrayList<>(new LinkedHashSet<>(orderedIds)); + if (ids.isEmpty()) { + return; + } + PermissionMenuEntity anchor = permissionMenuMapper.selectById(ids.get(0)); + if (anchor == null) { + throw new BusinessException("菜单不存在"); + } + Long parentId = anchor.getParentId(); + String menuType = anchor.getMenuType(); + List siblings = permissionMenuMapper.selectList(new LambdaQueryWrapper() + .eq(PermissionMenuEntity::getMenuType, menuType) + .eq(PermissionMenuEntity::getParentId, parentId) + .orderByAsc(PermissionMenuEntity::getSortOrder) + .orderByAsc(PermissionMenuEntity::getId)); + int minSort = siblings.isEmpty() ? 0 + : (siblings.get(0).getSortOrder() == null ? 0 : siblings.get(0).getSortOrder()); + Map remaining = siblings.stream() + .filter(s -> s.getId() != null) + .collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity(), (a, b) -> a)); + List ordered = new ArrayList<>(siblings.size()); + for (Long id : ids) { + PermissionMenuEntity sibling = remaining.remove(id); + if (sibling != null) { + ordered.add(sibling); + } + } + ordered.addAll(remaining.values()); + int sort = minSort; + for (PermissionMenuEntity sibling : ordered) { + if (!Objects.equals(sibling.getSortOrder(), sort)) { + permissionMenuMapper.update(null, new LambdaUpdateWrapper() + .eq(PermissionMenuEntity::getId, sibling.getId()) + .set(PermissionMenuEntity::getSortOrder, sort)); + } + sort += 10; + } + } + /** Returns only persisted direct grant IDs (never recursively expanded). */ public UserColumnIdsVo getUserColumnIds(Long userId, String menuType) { return getUserColumnIds(null, userId, menuType); @@ -314,6 +363,30 @@ public class PermissionMenuService { } } + /** + * 判断操作者是否可见任一所给后台菜单(按 column_key,限定 admin 菜单类型)。 + * 超管直通;其余取直接授权递归展开后的有效菜单做交集。 + */ + public boolean hasAnyAdminMenu(AdminUserEntity operator, List columnKeys) { + if (operator == null || operator.getId() == null || columnKeys == null || columnKeys.isEmpty()) { + return false; + } + if (isSuperAdmin(operator)) { + return true; + } + List menus = loadMenus(MENU_TYPE_ADMIN); + List wantedIds = menus.stream() + .filter(menu -> menu.getColumnKey() != null && columnKeys.contains(menu.getColumnKey())) + .map(PermissionMenuEntity::getId) + .filter(Objects::nonNull) + .toList(); + if (wantedIds.isEmpty()) { + return false; + } + Set effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus); + return wantedIds.stream().anyMatch(effective::contains); + } + @Transactional public void updateUserColumnPermissions(Long userId, UserColumnPermissionUpdateRequest request) { updateUserColumnPermissions(null, userId, request); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java new file mode 100644 index 00000000..66a786e5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java @@ -0,0 +1,324 @@ +package com.nanri.aiimage.modules.shopdatacrawl.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.util.DownloadHeaderUtil; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.service.PermissionMenuService; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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 org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 店铺数据抓取记录管理端(原生端点): + *
    + *
  • GET /api/admin/shop-data-crawl-tasks 按店铺分组的分页列表(每店最新结果文件)
  • + *
  • POST /api/admin/shop-data-crawl-tasks/download-zip 批量打包选中结果文件(部分失败经响应头提示)
  • + *
+ * 契约对齐 Flask 管理后台 shop-data-crawl-tasks 面板(snake_case 参数与返回字段)。 + * 鉴权:JWT 管理员或 Flask 内部代理(X-Internal-Token + operatorId),统一菜单+数据权限校验。 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/shop-data-crawl-tasks") +@Tag(name = "店铺数据抓取记录管理") +public class AdminShopDataCrawlTasksController { + + private static final int PAGE_SIZE_DEFAULT = 20; + private static final int PAGE_SIZE_MAX = 100; + private static final int ZIP_MAX_FILES = 100; + private static final Pattern INVALID_FILE_CHARS = Pattern.compile("[\\\\/:*?\"<>|]+"); + private static final DateTimeFormatter ZIP_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + @Value("${aiimage.security.internal-token:}") + private String internalToken; + + @Value("${aiimage.security.internal-token-file:}") + private String internalTokenFile; + + private final AdminAuthSupport adminAuthSupport; + private final PermissionMenuService permissionMenuService; + private final ShopDataCrawlAdminTasksService adminTasksService; + + @GetMapping + @Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)") + public ApiResponse list( + HttpServletRequest request, + @RequestParam(name = "page", required = false) String page, + @RequestParam(name = "page_size", required = false) String pageSize, + @RequestParam(name = "shop_name", required = false) String shopName, + @RequestParam(name = "group_name", required = false) String groupName, + @RequestParam(name = "country", required = false) String country, + @RequestParam(name = "created_from", required = false) String createdFrom, + @RequestParam(name = "created_to", required = false) String createdTo) { + requireShopDataCrawlTaskAccess(request); + int safePage = Math.max(1, parseInt(page, 1, "page 参数不合法")); + int safePageSize = Math.min(PAGE_SIZE_MAX, + Math.max(10, parseInt(pageSize, PAGE_SIZE_DEFAULT, "page_size 参数不合法"))); + Map payload = adminTasksService.listShopGroups( + safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo); + return ApiResponse.success(payload); + } + + @PostMapping("/download-zip") + @Operation(summary = "批量下载选中结果文件为 zip(文件部分失败经响应头 X-Archive-Error-Count 提示)") + public void downloadZip( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody(required = false) Map body) { + requireShopDataCrawlTaskAccess(request); + List resultIds = parseResultIds(body); + List rows = adminTasksService.loadDownloadRows(resultIds); + Map rowById = new HashMap<>(); + for (ShopDataCrawlDownloadRowDto row : rows) { + rowById.put(row.getResultId(), row); + } + + List errors = new ArrayList<>(); + Set usedNames = new HashSet<>(); + int fileCount = 0; + Path spool = null; + try { + spool = Files.createTempFile("shop-data-tasks-", ".zip"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(spool), StandardCharsets.UTF_8)) { + zip.setLevel(0); + boolean broken = false; + for (Long resultId : resultIds) { + if (broken) { + errors.add("result-" + resultId + ": 压缩中断,文件未处理"); + continue; + } + ShopDataCrawlDownloadRowDto row = rowById.get(resultId); + if (row == null || row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) { + errors.add("result-" + resultId + ": 结果文件不存在或尚未生成"); + continue; + } + String filename = resolveEntryFilename(row, resultId, usedNames); + try (InputStream input = URI.create(row.getResultFileUrl()).toURL().openStream()) { + zip.putNextEntry(new ZipEntry(filename)); + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + zip.write(buffer, 0, read); + } + zip.closeEntry(); + fileCount++; + } catch (Exception ex) { + log.warn("[shop-data-crawl-admin] 结果文件打包失败 result_id={} msg={}", resultId, ex.getMessage()); + errors.add(filename + ": 下载失败 (" + ex.getMessage() + ")"); + try { + zip.closeEntry(); + } catch (Exception closeEx) { + broken = true; + } + } + } + if (!errors.isEmpty()) { + try { + zip.putNextEntry(new ZipEntry("download-errors.txt")); + zip.write(String.join("\n", errors).getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } catch (Exception ex) { + log.warn("[shop-data-crawl-admin] 写入失败清单出错: {}", ex.getMessage()); + } + } + } + response.setContentType("application/zip"); + DownloadHeaderUtil.setAttachment(response, + "shop-data-tasks-" + LocalDateTime.now().format(ZIP_STAMP) + ".zip"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Archive-File-Count", String.valueOf(fileCount)); + response.setHeader("X-Archive-Error-Count", String.valueOf(errors.size())); + Files.copy(spool, response.getOutputStream()); + response.getOutputStream().flush(); + } catch (IOException ex) { + log.warn("[shop-data-crawl-admin] 批量打包响应中断: {}", ex.getMessage()); + } catch (Exception ex) { + log.error("[shop-data-crawl-admin] 批量打包失败", ex); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "压缩包生成失败"); + } finally { + if (spool != null) { + try { + Files.deleteIfExists(spool); + } catch (IOException ignored) { + // 临时文件清理失败不影响响应 + } + } + } + } + + /** zip 内文件名:非法字符转下划线;重名时追加 result_id;空名回退 result-{id}.xlsx。 */ + private String resolveEntryFilename(ShopDataCrawlDownloadRowDto row, Long resultId, Set usedNames) { + String raw = row.getResultFilename(); + if (raw == null || raw.isBlank()) { + raw = (row.getSourceFilename() == null || row.getSourceFilename().isBlank()) + ? "result-" + resultId + ".xlsx" : row.getSourceFilename(); + } + String filename = INVALID_FILE_CHARS.matcher(raw).replaceAll("_").trim(); + if (filename.isEmpty()) { + filename = "result-" + resultId + ".xlsx"; + } + if (usedNames.contains(filename)) { + int dotIndex = filename.lastIndexOf('.'); + String stem = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; + String extension = dotIndex > 0 ? filename.substring(dotIndex) : ""; + if (extension.isBlank()) { + extension = ".xlsx"; + } + filename = stem + "-" + resultId + extension; + } + usedNames.add(filename); + return filename; + } + + /** 解析并校验 body:{result_ids|resultIds: [..]},1~100 个正整数,去重保序。 */ + private List parseResultIds(Map body) { + Object raw = body == null ? null + : (body.containsKey("result_ids") ? body.get("result_ids") : body.get("resultIds")); + if (!(raw instanceof List items) || items.isEmpty()) { + throw new BusinessException(400, "请至少选择一个结果文件"); + } + if (items.size() > ZIP_MAX_FILES) { + throw new BusinessException(400, "单次最多打包 100 个结果文件"); + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (Object item : items) { + long value; + if (item instanceof Number number) { + value = number.longValue(); + } else if (item instanceof String text && text.matches("\\d+")) { + value = Long.parseLong(text); + } else { + throw new BusinessException(400, "结果文件参数无效"); + } + if (value <= 0) { + throw new BusinessException(400, "结果文件参数无效"); + } + ids.add(value); + } + return new ArrayList<>(ids); + } + + private void requireShopDataCrawlTaskAccess(HttpServletRequest request) { + AdminUserEntity operator; + try { + operator = adminAuthSupport.requireAdmin(request); + } catch (BusinessException authFailure) { + operator = resolveInternalOperator(request); + if (operator == null) { + throw authFailure; + } + } + permissionMenuService.requireShopDataCrawlTaskAccess(operator); + } + + private AdminUserEntity resolveInternalOperator(HttpServletRequest request) { + String suppliedToken = request.getHeader("X-Internal-Token"); + if (!isTrustedInternalRequest(suppliedToken)) { + return null; + } + String rawOperatorId = request.getParameter("operatorId"); + if (rawOperatorId == null || rawOperatorId.isBlank()) { + rawOperatorId = request.getParameter("operator_id"); + } + if (rawOperatorId == null || rawOperatorId.isBlank()) { + return null; + } + try { + return permissionMenuService.requireAdminOperator(Long.parseLong(rawOperatorId.trim())); + } catch (NumberFormatException ex) { + return null; + } + } + + private boolean isTrustedInternalRequest(String suppliedToken) { + String expectedToken = resolveExpectedInternalToken(); + return !expectedToken.isBlank() && suppliedToken != null && !suppliedToken.isBlank() + && MessageDigest.isEqual( + expectedToken.getBytes(StandardCharsets.UTF_8), + suppliedToken.trim().getBytes(StandardCharsets.UTF_8)); + } + + private String resolveExpectedInternalToken() { + if (internalToken != null && !internalToken.isBlank()) { + return internalToken.trim(); + } + Path path = resolveInternalTokenFile(); + if (path == null || !Files.isRegularFile(path)) { + return ""; + } + try { + return Files.readString(path, StandardCharsets.UTF_8).trim(); + } catch (Exception ignored) { + return ""; + } + } + + private Path resolveInternalTokenFile() { + String configuredPath = internalTokenFile == null ? "" : internalTokenFile.trim(); + if (!configuredPath.isEmpty()) { + if (configuredPath.equals("~") || configuredPath.startsWith("~/") || configuredPath.startsWith("~\\")) { + String userHome = System.getProperty("user.home", "").trim(); + if (userHome.isEmpty()) { + return null; + } + configuredPath = configuredPath.length() == 1 + ? userHome + : Path.of(userHome, configuredPath.substring(2)).toString(); + } + Path configuredTokenPath = Path.of(configuredPath); + return configuredTokenPath.isAbsolute() ? configuredTokenPath.normalize() : null; + } + String userHome = System.getProperty("user.home", "").trim(); + return userHome.isEmpty() + ? null + : Path.of(userHome, ".aiimage", "internal-token").toAbsolutePath().normalize(); + } + + private static int parseInt(String raw, int defaultValue, String errorMessage) { + if (raw == null || raw.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ex) { + throw new BusinessException(400, errorMessage); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java new file mode 100644 index 00000000..8466498e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java @@ -0,0 +1,185 @@ +package com.nanri.aiimage.modules.shopdatacrawl.mapper; + +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminFileJobBriefDto; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupLabelDto; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupRow; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminRow; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.SelectProvider; + +import java.util.List; +import java.util.Map; + +/** + * 店铺数据抓取记录"按店铺分组列表"与批量下载的数据源查询, + * 语义逐条对照 Flask admin_api.py 中 list_shop_data_crawl_tasks / + * _load_shop_data_crawl_download_rows 的 SQL。 + * + *

参数统一走单参 Map:筛选与分页字段用语义键(shopName/groupName/country/ + * createdFrom/createdTo/pageLimit/pageOffset),IN 列表键 shopNames / resultIds, + * 占位元素键为 sn0..snN / ri0..riN(调用方在 Service 内按序铺平,避免动态列表绑定歧义)。 + */ +@Mapper +public interface ShopDataCrawlAdminTasksMapper { + + /** 店铺分组总数(按 TRIM(source_filename) 去重后的组数)。 */ + @SelectProvider(type = SqlProvider.class, method = "countShopGroups") + long countShopGroups(Map p); + + /** 当前页店铺分组(每店最新归档时间)。 */ + @SelectProvider(type = SqlProvider.class, method = "selectShopGroupPage") + List selectShopGroupPage(Map p); + + /** 当前页每家店铺的最新结果行(窗口函数 row_number=1),需先铺平 shopNames。 */ + @SelectProvider(type = SqlProvider.class, method = "selectLatestRowsForShops") + List selectLatestRowsForShops(Map p); + + /** 店铺 → 分组名映射,需先铺平 shopNames。 */ + @SelectProvider(type = SqlProvider.class, method = "selectGroupLabels") + List selectGroupLabels(Map p); + + /** 每个结果文件最新一条 ASSEMBLE_RESULT 任务(按 job id 取最大),需先铺平 resultIds。 */ + @SelectProvider(type = SqlProvider.class, method = "selectLatestAssembleJobs") + List selectLatestAssembleJobs(Map p); + + /** 批量下载 zip 所需结果行,需先铺平 resultIds。 */ + @SelectProvider(type = SqlProvider.class, method = "selectDownloadRows") + List selectDownloadRows(Map p); + + class SqlProvider { + + private static final String LATEST_TIME = "COALESCE(df.last_success_at, df.updated_at, " + + "t.finished_at, t.updated_at, t.created_at)"; + + private static final String SHOP_KEY = "TRIM(COALESCE(r.source_filename, ''))"; + + private static final String FROM_WHERE_BASE = + " FROM biz_file_result r " + + "JOIN biz_file_task t ON t.id = r.task_id " + + "LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id " + + "LEFT JOIN users u ON u.id = r.user_id " + + "WHERE r.module_type = 'SHOP_DATA_CRAWL' " + + "AND t.module_type = 'SHOP_DATA_CRAWL' " + + "AND TRIM(COALESCE(r.result_file_url, '')) <> ''"; + + public String countShopGroups(Map p) { + return "SELECT COUNT(*) AS total FROM (SELECT " + SHOP_KEY + " AS shopKey" + + FROM_WHERE_BASE + whereTail(p, "") + " GROUP BY " + SHOP_KEY + ") grouped_shops"; + } + + public String selectShopGroupPage(Map p) { + return "SELECT " + SHOP_KEY + " AS shopName, MAX(" + LATEST_TIME + ") AS latestCreatedAt" + + FROM_WHERE_BASE + whereTail(p, "") + + " GROUP BY " + SHOP_KEY + + " ORDER BY latestCreatedAt DESC, shopName ASC" + + " LIMIT #{pageLimit} OFFSET #{pageOffset}"; + } + + public String selectLatestRowsForShops(Map p) { + return "SELECT ranked.resultId, ranked.taskId, ranked.userId, ranked.username," + + " ranked.shopName, ranked.shopId, ranked.resultFilename, ranked.resultFileUrl," + + " ranked.resultFileSize, ranked.resultContentType, ranked.rowCount," + + " ranked.resultSuccess, ranked.resultError, ranked.resultCreatedAt," + + " ranked.taskNo, ranked.taskStatus, ranked.requestJson, ranked.taskError," + + " ranked.createdAt, ranked.updatedAt, ranked.finishedAt," + + " ranked.latestFileUpdatedAt, ranked.countryCodesJson, ranked.rowCountDisplay" + + " FROM (SELECT r.id AS resultId, r.task_id AS taskId, r.user_id AS userId," + + " u.username AS username," + + " r.source_filename AS shopName, r.source_file_url AS shopId," + + " r.result_filename AS resultFilename, r.result_file_url AS resultFileUrl," + + " r.result_file_size AS resultFileSize, r.result_content_type AS resultContentType," + + " r.row_count AS rowCount, r.success AS resultSuccess," + + " r.error_message AS resultError, r.created_at AS resultCreatedAt," + + " t.task_no AS taskNo, t.status AS taskStatus, t.request_json AS requestJson," + + " t.error_message AS taskError, t.created_at AS createdAt," + + " t.updated_at AS updatedAt, t.finished_at AS finishedAt," + + " " + LATEST_TIME + " AS latestFileUpdatedAt," + + " df.country_codes_json AS countryCodesJson," + + " COALESCE(df.row_count, r.row_count) AS rowCountDisplay," + + " ROW_NUMBER() OVER (PARTITION BY " + SHOP_KEY + + " ORDER BY " + LATEST_TIME + " DESC, r.id DESC) AS rowNo" + + FROM_WHERE_BASE + + whereTail(p, " AND " + SHOP_KEY + " IN (" + inPlaceholders(p, "shopNames", "sn") + ")") + + ") ranked WHERE ranked.rowNo = 1" + + " ORDER BY ranked.latestFileUpdatedAt DESC, ranked.resultId DESC"; + } + + public String selectGroupLabels(Map p) { + return "SELECT TRIM(sm.shop_name) AS shopName," + + " GROUP_CONCAT(DISTINCT COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, ''))" + + " ORDER BY sm.id SEPARATOR '、') AS groupName" + + " FROM biz_shop_manage sm" + + " LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id" + + " WHERE TRIM(sm.shop_name) IN (" + inPlaceholders(p, "shopNames", "sn") + ")" + + " GROUP BY TRIM(sm.shop_name)"; + } + + public String selectLatestAssembleJobs(Map p) { + return "SELECT fj.result_id AS resultId, fj.id AS fileJobId," + + " fj.status AS fileStatus, fj.error_message AS fileError" + + " FROM biz_task_file_job fj" + + " INNER JOIN (SELECT result_id, MAX(id) AS max_id FROM biz_task_file_job" + + " WHERE module_type = 'SHOP_DATA_CRAWL' AND job_type = 'ASSEMBLE_RESULT'" + + " AND result_id IN (" + inPlaceholders(p, "resultIds", "ri") + ")" + + " GROUP BY result_id) latest ON fj.id = latest.max_id"; + } + + public String selectDownloadRows(Map p) { + return "SELECT r.id AS resultId, r.task_id AS taskId, r.user_id AS userId," + + " r.source_filename AS sourceFilename, r.result_filename AS resultFilename," + + " r.result_file_url AS resultFileUrl, t.status AS taskStatus" + + " FROM biz_file_result r" + + " JOIN biz_file_task t ON t.id = r.task_id" + + " WHERE r.module_type = 'SHOP_DATA_CRAWL' AND t.module_type = 'SHOP_DATA_CRAWL'" + + " AND r.id IN (" + inPlaceholders(p, "resultIds", "ri") + ")"; + } + + /** 追加可选的店铺/分组/国家/时间筛选。 */ + private static String whereTail(Map p, String extraCondition) { + StringBuilder sql = new StringBuilder(extraCondition == null ? "" : extraCondition); + String shopName = (String) p.get("shopName"); + if (shopName != null && !shopName.isBlank()) { + sql.append(" AND r.source_filename LIKE CONCAT('%', #{shopName}, '%')"); + } + String groupName = (String) p.get("groupName"); + if (groupName != null && !groupName.isBlank()) { + sql.append(" AND EXISTS (SELECT 1 FROM biz_shop_manage sm" + + " LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id" + + " WHERE TRIM(COALESCE(sm.shop_name, '')) = " + + "TRIM(COALESCE(r.source_filename, ''))" + + " AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, ''))" + + " LIKE CONCAT('%', #{groupName}, '%'))"); + } + if (p.get("country") != null && !((String) p.get("country")).isBlank()) { + sql.append(" AND JSON_CONTAINS(COALESCE(df.country_codes_json," + + " JSON_EXTRACT(t.request_json, '$.countryCodes')," + + " JSON_EXTRACT(t.request_json, '$.country_codes'), '[]')," + + " CONCAT('\"', #{country}, '\"'))"); + } + if (p.get("createdFrom") != null) { + sql.append(" AND t.created_at >= #{createdFrom}"); + } + if (p.get("createdTo") != null) { + sql.append(" AND t.created_at <= #{createdTo}"); + } + return sql.toString(); + } + + /** 列表长度对应占位符 #{prefix0}, #{prefix1}...;空列表返回空字符串,调用方须保证非空。 */ + private static String inPlaceholders(Map p, String listKey, String prefix) { + Object raw = p.get(listKey); + if (!(raw instanceof List items) || items.isEmpty()) { + return ""; + } + StringBuilder sql = new StringBuilder(); + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append("#{").append(prefix).append(i).append('}'); + } + return sql.toString(); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java new file mode 100644 index 00000000..91797a69 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.shopdatacrawl.model.dto; + +import lombok.Data; + +/** + * 结果文件最近的 ASSEMBLE_RESULT 文件任务(按 job id 取最新,语义对齐 Flask)。 + */ +@Data +public class ShopDataCrawlAdminFileJobBriefDto { + + private Long resultId; + private Long fileJobId; + private String fileStatus; + private String fileError; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java new file mode 100644 index 00000000..70a199d7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java @@ -0,0 +1,13 @@ +package com.nanri.aiimage.modules.shopdatacrawl.model.dto; + +import lombok.Data; + +/** + * 店铺 → 分组名(g.group_name 优先于 sm.group_name,多分组「、」连接)。 + */ +@Data +public class ShopDataCrawlAdminGroupLabelDto { + + private String shopName; + private String groupName; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java new file mode 100644 index 00000000..6008fdb0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.shopdatacrawl.model.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 按店铺分组列表的一行(店铺名 + 该店全部匹配结果中最新的归档时间),用于分页组集合。 + */ +@Data +public class ShopDataCrawlAdminGroupRow { + + private String shopName; + private LocalDateTime latestCreatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java new file mode 100644 index 00000000..94d63694 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java @@ -0,0 +1,41 @@ +package com.nanri.aiimage.modules.shopdatacrawl.model.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 管理列表"每店最新结果行"(窗口函数 row_number=1 结果),字段对齐 Flask + * _SHOP_DATA_CRAWL_ADMIN_COLUMNS 的列集。 + */ +@Data +public class ShopDataCrawlAdminRow { + + private Long resultId; + private Long taskId; + private Long userId; + private String username; + /** r.source_filename 原始值(可能带首尾空格,展示用,与 Flask 一致)。 */ + private String shopName; + /** r.source_file_url,语义为店铺页 URL。 */ + private String shopId; + private String resultFilename; + private String resultFileUrl; + private Long resultFileSize; + private String resultContentType; + private Integer rowCount; + /** 结果成功标志:-1 处理中 / 0 失败 / 1 成功。 */ + private Integer resultSuccess; + private String resultError; + private LocalDateTime resultCreatedAt; + private String taskNo; + private String taskStatus; + private String requestJson; + private String taskError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime finishedAt; + private LocalDateTime latestFileUpdatedAt; + private String countryCodesJson; + private Integer rowCountDisplay; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java new file mode 100644 index 00000000..2c0ca318 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.shopdatacrawl.model.dto; + +import lombok.Data; + +/** + * 批量下载 zip 需要的行数据(等价 Flask _load_shop_data_crawl_download_rows)。 + */ +@Data +public class ShopDataCrawlDownloadRowDto { + + private Long resultId; + private Long taskId; + private Long userId; + private String sourceFilename; + private String resultFilename; + private String resultFileUrl; + private String taskStatus; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java new file mode 100644 index 00000000..c2f54aa9 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java @@ -0,0 +1,333 @@ +package com.nanri.aiimage.modules.shopdatacrawl.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.shopdatacrawl.mapper.ShopDataCrawlAdminTasksMapper; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminFileJobBriefDto; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupLabelDto; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupRow; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminRow; +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 店铺数据抓取记录管理端查询组装:按店铺分组、每店取最新结果(等价 Flask + * admin_api.py list_shop_data_crawl_tasks),以及批量 zip 的行数据加载。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ShopDataCrawlAdminTasksService { + + private static final DateTimeFormatter ADMIN_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set SUPPORTED_COUNTRIES = Set.of("DE", "UK", "FR", "IT", "ES"); + + private final ShopDataCrawlAdminTasksMapper adminTasksMapper; + private final ObjectMapper objectMapper; + + /** + * 分页列表组装(组总数 → 当前页店铺组 → 每店最新结果行 + 文件任务/分组名补充)。 + */ + public Map listShopGroups(int page, int pageSize, String shopName, String groupName, + String country, String createdFromRaw, String createdToRaw) { + LocalDateTime createdFrom = parseAdminDateTime("created_from", createdFromRaw); + LocalDateTime createdTo = parseAdminDateTime("created_to", createdToRaw); + String countryFilter = blankToNull(country); + if (countryFilter != null) { + countryFilter = countryFilter.trim().toUpperCase(Locale.ROOT); + if (!SUPPORTED_COUNTRIES.contains(countryFilter)) { + throw new BusinessException(400, "不支持的国家代码: " + countryFilter); + } + } + Map p = new HashMap<>(); + p.put("shopName", blankToNull(shopName)); + p.put("groupName", blankToNull(groupName)); + p.put("country", countryFilter); + p.put("createdFrom", createdFrom); + p.put("createdTo", createdTo); + + long total = adminTasksMapper.countShopGroups(p); + List> items = new ArrayList<>(); + if (total > 0) { + p.put("pageLimit", pageSize); + p.put("pageOffset", (page - 1) * pageSize); + List groups = adminTasksMapper.selectShopGroupPage(p); + if (!groups.isEmpty()) { + items = assembleGroupItems(p, groups); + } + } + Map payload = new LinkedHashMap<>(); + payload.put("items", items); + payload.put("total", total); + payload.put("page", page); + payload.put("page_size", pageSize); + return payload; + } + + /** 批量下载 zip 所需结果行(仅存在行,缺失 id 由调用方自行记为部分失败)。 */ + public List loadDownloadRows(List resultIds) { + if (resultIds == null || resultIds.isEmpty()) { + return List.of(); + } + Map p = new HashMap<>(); + flattenList(p, resultIds, "resultIds", "ri"); + return adminTasksMapper.selectDownloadRows(p); + } + + private List> assembleGroupItems(Map p, List groups) { + List shopNames = new ArrayList<>(); + for (ShopDataCrawlAdminGroupRow group : groups) { + shopNames.add(group.getShopName() == null ? "" : group.getShopName()); + } + flattenList(p, shopNames, "shopNames", "sn"); + + // 分组名:店铺 → 分组(group_name 优先,多分组连接) + Map labelByShop = new HashMap<>(); + for (ShopDataCrawlAdminGroupLabelDto label : adminTasksMapper.selectGroupLabels(p)) { + labelByShop.put(normalizeShopKey(label.getShopName()), label.getGroupName() == null ? "" : label.getGroupName()); + } + + // 当前页每家店铺最新结果行,按店铺归一化名聚组 + List rows = adminTasksMapper.selectLatestRowsForShops(p); + Map> rowsByShop = new LinkedHashMap<>(); + for (ShopDataCrawlAdminRow row : rows) { + rowsByShop.computeIfAbsent(normalizeShopKey(row.getShopName()), key -> new ArrayList<>()).add(row); + } + + // 结果文件最近一次 ASSEMBLE_RESULT 任务(文件组装状态/错误) + Map jobByResult = loadAssembleJobMap(rows); + + List> items = new ArrayList<>(groups.size()); + for (ShopDataCrawlAdminGroupRow group : groups) { + List children = rowsByShop.getOrDefault( + normalizeShopKey(group.getShopName()), List.of()); + List> results = new ArrayList<>(); + if (!children.isEmpty()) { + results.add(toItemMap(children.get(0), jobByResult.get(children.get(0).getResultId()), + labelByShop)); + } + LocalDateTime latest = group.getLatestCreatedAt(); + if (latest == null && !children.isEmpty()) { + latest = children.get(0).getLatestFileUpdatedAt(); + } + if (latest == null && !children.isEmpty()) { + latest = children.get(0).getResultCreatedAt(); + } + Map groupItem = new LinkedHashMap<>(); + groupItem.put("shop_name", isBlank(group.getShopName()) ? "未命名" : group.getShopName()); + groupItem.put("shop_id", results.isEmpty() + ? "" : results.get(0).getOrDefault("shop_id", "")); + groupItem.put("group_name", groupLabel(labelByShop, group.getShopName())); + groupItem.put("latest_created_at", formatAdminTime(latest)); + groupItem.put("results", results); + items.add(groupItem); + } + return items; + } + + private Map loadAssembleJobMap(List rows) { + if (rows.isEmpty()) { + return Map.of(); + } + List resultIds = new ArrayList<>(); + for (ShopDataCrawlAdminRow row : rows) { + if (row.getResultId() != null && row.getResultId() > 0) { + resultIds.add(row.getResultId()); + } + } + List distinctIds = new ArrayList<>(new LinkedHashSet<>(resultIds)); + if (distinctIds.isEmpty()) { + return Map.of(); + } + Map p = new HashMap<>(); + flattenList(p, distinctIds, "resultIds", "ri"); + Map jobByResult = new HashMap<>(); + for (ShopDataCrawlAdminFileJobBriefDto job : adminTasksMapper.selectLatestAssembleJobs(p)) { + if (job.getResultId() != null) { + jobByResult.put(job.getResultId(), job); + } + } + return jobByResult; + } + + /** 单个"最新结果行"→ 前端字段 Map(键 snake_case,对齐 Flask _shop_data_crawl_admin_item)。 */ + private Map toItemMap(ShopDataCrawlAdminRow row, + ShopDataCrawlAdminFileJobBriefDto job, + Map labelByShop) { + boolean fileReady = row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank(); + Map m = new LinkedHashMap<>(); + m.put("task_id", row.getTaskId()); + m.put("task_no", defaultText(row.getTaskNo())); + m.put("result_id", row.getResultId()); + m.put("user_id", row.getUserId()); + m.put("username", defaultText(row.getUsername())); + m.put("shop_name", defaultText(row.getShopName())); + m.put("shop_id", defaultText(row.getShopId())); + m.put("group_name", groupLabel(labelByShop, row.getShopName())); + m.put("status", defaultText(row.getTaskStatus())); + Integer success = row.getResultSuccess(); + m.put("success", success == null || success < 0 ? null : Integer.valueOf(1).equals(success)); + m.put("error", firstNonBlank(row.getResultError(), row.getTaskError(), + job == null ? null : job.getFileError())); + m.put("country_codes", countryCodesOf(row.getCountryCodesJson(), row.getRequestJson())); + m.put("output_filename", defaultText(row.getResultFilename())); + m.put("result_file_url", defaultText(row.getResultFileUrl())); + m.put("file_ready", fileReady); + m.put("file_job_id", job == null ? null : job.getFileJobId()); + m.put("file_status", fileStatusOf(job, fileReady)); + m.put("file_error", job == null || job.getFileError() == null ? "" : job.getFileError()); + m.put("file_size", row.getResultFileSize() == null ? 0L : row.getResultFileSize()); + Integer displayRowCount = row.getRowCountDisplay() == null ? row.getRowCount() : row.getRowCountDisplay(); + m.put("row_count", displayRowCount == null ? 0 : displayRowCount); + m.put("created_at", formatAdminTime( + row.getCreatedAt() == null ? row.getResultCreatedAt() : row.getCreatedAt())); + m.put("updated_at", formatAdminTime(row.getUpdatedAt())); + m.put("finished_at", formatAdminTime(row.getFinishedAt())); + return m; + } + + private String fileStatusOf(ShopDataCrawlAdminFileJobBriefDto job, boolean fileReady) { + if (job != null && job.getFileStatus() != null && !job.getFileStatus().isBlank()) { + return job.getFileStatus(); + } + return fileReady ? "SUCCESS" : ""; + } + + private String groupLabel(Map labelByShop, String shopName) { + String value = labelByShop.get(normalizeShopKey(shopName)); + return value == null ? "" : value; + } + + /** 店铺 key 归一化:trim + 小写(Flask casefold 语义,兼容 DB 内尾随空白差异)。 */ + private static String normalizeShopKey(String shopName) { + return (shopName == null ? "" : shopName.trim().toLowerCase(Locale.ROOT)); + } + + /** 国家代码解析:df.country_codes_json 优先,空则回退 request_json 的 countryCodes/country_codes。 */ + private List countryCodesOf(String countryCodesJson, String requestJson) { + List codes = parseStringArray(countryCodesJson); + if (codes.isEmpty()) { + codes = requestCountryCodes(requestJson); + } + return codes; + } + + private List requestCountryCodes(String requestJson) { + if (requestJson == null || requestJson.isBlank()) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(requestJson); + if (root == null || !root.isObject()) { + return List.of(); + } + JsonNode array = root.get("countryCodes"); + if (array == null) { + array = root.get("country_codes"); + } + if (array == null || !array.isArray()) { + return List.of(); + } + return parseArrayNodes(array); + } catch (Exception ex) { + log.warn("[shop-data-crawl-admin] request_json 国家代码解析失败: {}", ex.getMessage()); + return List.of(); + } + } + + private List parseStringArray(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode array = objectMapper.readTree(json); + return array != null && array.isArray() ? parseArrayNodes(array) : List.of(); + } catch (Exception ex) { + log.warn("[shop-data-crawl-admin] country_codes_json 解析失败: {}", ex.getMessage()); + return List.of(); + } + } + + private List parseArrayNodes(JsonNode array) { + List codes = new ArrayList<>(); + for (JsonNode node : array) { + if (!node.isContainerNode()) { + String code = node.asText("").trim().toUpperCase(Locale.ROOT); + if (!code.isEmpty()) { + codes.add(code); + } + } + } + return codes; + } + + /** 时间参数解析:兼容空格/T 分隔、可选秒/时、尾随 Z,失败时给出与 Flask 一致的提示。 */ + private static LocalDateTime parseAdminDateTime(String name, String raw) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + return null; + } + String normalized = value.replace('T', ' ').replaceAll("[Zz]$", "").replaceAll("\\.\\d+$", "").trim(); + for (DateTimeFormatter formatter : List.of( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH"), + DateTimeFormatter.ofPattern("yyyy-MM-dd"))) { + try { + return LocalDateTime.parse(normalized, formatter); + } catch (DateTimeParseException ignored) { + // 尝试下一种格式 + } + } + throw new BusinessException(400, name + " 时间格式无效"); + } + + private static String formatAdminTime(LocalDateTime value) { + return value == null ? "" : value.format(ADMIN_TIME); + } + + private static String firstNonBlank(String first, String... others) { + String cursor = first; + for (String other : others) { + if (cursor == null || cursor.isBlank()) { + cursor = other; + } + } + return cursor == null ? "" : cursor; + } + + private static String defaultText(String value) { + return value == null ? "" : value; + } + + private static String blankToNull(String value) { + return (value == null || value.isBlank()) ? null : value.trim(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /** 将列表铺平为 "#{prefix0}..#{prefixN}" 可用的占位键,避免动态列表绑定歧义。 */ + private static void flattenList(Map p, List values, String listKey, String prefix) { + p.put(listKey, values); + for (int i = 0; i < values.size(); i++) { + p.put(prefix + i, values.get(i)); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/SoftwareVersionAdminController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/SoftwareVersionAdminController.java new file mode 100644 index 00000000..293a3347 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/SoftwareVersionAdminController.java @@ -0,0 +1,56 @@ +package com.nanri.aiimage.modules.softwareversion.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.softwareversion.service.SoftwareVersionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.multipart.MultipartFile; + +import java.util.Map; + +/** + * 客户端软件版本管理(替代 Flask /api/admin/versions、/api/admin/version 本地实现)。 + *

读写旧表 web_config;"数字人版本"走 digitalhuman 模块(/api/digital-human/versions),与本接口无关。 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin") +@Tag(name = "客户端软件版本管理", description = "桌面客户端软件安装包版本管理:列表与上传,数据落 web_config 表") +public class SoftwareVersionAdminController { + + private final SoftwareVersionService softwareVersionService; + private final AdminAuthSupport adminAuthSupport; + + @GetMapping("/versions") + @Operation(summary = "客户端软件版本列表", description = "按创建时间倒序返回全部版本(旧 web_config 表)") + public ApiResponse> listVersions(HttpServletRequest request) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[software-version] 管理端查询客户端软件版本列表 operator={}", operator.getUsername()); + return ApiResponse.success(Map.of("items", softwareVersionService.listSoftwareVersions())); + } + + @PostMapping(value = "/version", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传客户端软件版本", description = "multipart 表单:version 版本号 + file zip 安装包,上传 MinIO 并写入 web_config") + public ApiResponse> uploadVersion( + HttpServletRequest request, + @Parameter(description = "版本号,如 1.0.46") @RequestParam(value = "version", required = false) String version, + @Parameter(description = "zip 安装包文件") @RequestParam(value = "file", required = false) MultipartFile file) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[software-version] 管理端上传客户端软件版本开始 operator={} version={}", operator.getUsername(), version); + Map result = softwareVersionService.uploadSoftwareVersion(version, file); + return ApiResponse.success("上传成功", result); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/mapper/SoftwareVersionMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/mapper/SoftwareVersionMapper.java new file mode 100644 index 00000000..bc0cd248 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/mapper/SoftwareVersionMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.softwareversion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.softwareversion.model.entity.SoftwareVersionEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SoftwareVersionMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/model/entity/SoftwareVersionEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/model/entity/SoftwareVersionEntity.java new file mode 100644 index 00000000..36943f04 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/model/entity/SoftwareVersionEntity.java @@ -0,0 +1,29 @@ +package com.nanri.aiimage.modules.softwareversion.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; + +/** + * 客户端软件版本记录(对应旧表 web_config,桌面客户端"检测更新"与 Flask 时代共用同一张表)。 + *

注意:本表不是"数字人版本"(数字人走 biz_digital_human_version,勿混淆)。 + */ +@Data +@TableName("web_config") +public class SoftwareVersionEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 版本号,如 1.0.46 */ + private String version; + + /** 安装包可公开访问的下载链接(MinIO path-style 直链) */ + private String fileUrl; + + /** 创建时间,由数据库 CURRENT_TIMESTAMP 兜底 */ + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java new file mode 100644 index 00000000..53e3c62d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java @@ -0,0 +1,149 @@ +package com.nanri.aiimage.modules.softwareversion.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.softwareversion.mapper.SoftwareVersionMapper; +import com.nanri.aiimage.modules.softwareversion.model.entity.SoftwareVersionEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 客户端软件(桌面 ShuFuAI.exe)版本管理服务:读写 web_config 表, + * 与 Flask 时代 /api/admin/versions、/api/admin/version 行为对齐(读取/追加写,无覆盖、无删除)。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SoftwareVersionService { + + /** MinIO 对象 key 前缀,对齐 Flask bucket_path(oss_bucket_path 默认 nanri-image/) 的历史约定 */ + public static final String STORAGE_PATH_PREFIX = "nanri-image/versions/"; + + /** 与 Flask VERSION_UPLOAD_MAX_BYTES(默认 512MB)保持一致 */ + private static final long MAX_UPLOAD_BYTES = 512L * 1024 * 1024; + + private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + /** 对齐 Python re.sub(r'[^\w.\-]', '_')(\w 含中文等 Unicode 字符),仅版本号片段防注入 */ + private static final Pattern UNSAFE_KEY_CHARS = + Pattern.compile("[^\\w.\\-]", Pattern.UNICODE_CHARACTER_CLASS); + + private final SoftwareVersionMapper softwareVersionMapper; + private final OssStorageService ossStorageService; + + /** + * 版本列表:web_config 全量按创建时间倒序,与 Flask 返回结构一致 + * (items 内为 id / version / file_url / created_at)。 + */ + public List> listSoftwareVersions() { + List entities = softwareVersionMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(SoftwareVersionEntity::getCreatedAt) + .orderByDesc(SoftwareVersionEntity::getId)); + log.info("[software-version] 查询客户端软件版本列表 count={}", entities.size()); + List> items = new ArrayList<>(entities.size()); + for (SoftwareVersionEntity entity : entities) { + Map item = new LinkedHashMap<>(); + item.put("id", entity.getId()); + item.put("version", entity.getVersion() == null ? "" : entity.getVersion()); + item.put("file_url", entity.getFileUrl() == null ? "" : entity.getFileUrl()); + item.put("created_at", entity.getCreatedAt() == null + ? "" : entity.getCreatedAt().format(CREATED_AT_FORMATTER)); + items.add(item); + } + return items; + } + + /** + * 上传客户端软件安装包:zip 上传 MinIO(key:nanri-image/versions/{安全版本号}.zip), + * 并向 web_config 追加一条记录(不做"设为最新",桌面端按 created_at 倒序取第一条)。 + */ + public Map uploadSoftwareVersion(String version, MultipartFile file) { + String normalizedVersion = version == null ? "" : version.trim(); + if (normalizedVersion.isEmpty()) { + throw new BusinessException("请填写版本号"); + } + if (file == null || file.getOriginalFilename() == null || file.getOriginalFilename().trim().isEmpty()) { + throw new BusinessException("请选择要上传的 zip 压缩包"); + } + String originalFilename = file.getOriginalFilename().trim(); + if (!originalFilename.toLowerCase(Locale.ROOT).endsWith(".zip")) { + throw new BusinessException("仅支持 .zip 格式"); + } + long fileSize = file.getSize(); + if (fileSize <= 0) { + throw new BusinessException("文件为空"); + } + if (fileSize > MAX_UPLOAD_BYTES) { + throw new BusinessException("文件超过允许的大小限制"); + } + + long startedAt = System.nanoTime(); + log.info("[software-version] 上传客户端软件版本开始 version={} filename={} requestFileSize={}", + normalizedVersion, originalFilename, fileSize); + + File tempFile = null; + try { + tempFile = Files.createTempFile("software-version-", ".zip").toFile(); + file.transferTo(tempFile); + log.info("[software-version] 临时文件已保存 version={} bytes={} elapsedMs={}", + normalizedVersion, tempFile.length(), elapsedMs(startedAt)); + + String safeKey = safeVersionKey(normalizedVersion); + String objectKey = STORAGE_PATH_PREFIX + safeKey + ".zip"; + // 上传后返回可直接公开下载的 URL,与桌面端"检测更新"返回的 file_url 格式一致 + String fileUrl = ossStorageService.uploadSoftwareVersionPackage(tempFile, objectKey); + log.info("[software-version] MinIO 上传成功 version={} objectKey={} bytes={} url={} elapsedMs={}", + normalizedVersion, objectKey, tempFile.length(), fileUrl, elapsedMs(startedAt)); + + SoftwareVersionEntity entity = new SoftwareVersionEntity(); + entity.setVersion(normalizedVersion); + entity.setFileUrl(fileUrl); + // 不显式写 created_at,走数据库 CURRENT_TIMESTAMP 默认值(与 Flask 行为一致) + softwareVersionMapper.insert(entity); + log.info("[software-version] 版本记录已写入 web_config id={} version={} elapsedMs={}", + entity.getId(), normalizedVersion, elapsedMs(startedAt)); + + Map result = new LinkedHashMap<>(); + result.put("version", normalizedVersion); + result.put("file_url", fileUrl); + return result; + } catch (IOException e) { + log.error("[software-version] 上传客户端软件版本文件处理失败 version={} filename={} err={}", + normalizedVersion, originalFilename, e.getMessage(), e); + throw new BusinessException("文件处理失败:" + e.getMessage()); + } catch (RuntimeException e) { + log.error("[software-version] 上传客户端软件版本失败 version={} filename={} elapsedMs={} err={}", + normalizedVersion, originalFilename, elapsedMs(startedAt), e.getMessage(), e); + throw e; + } finally { + if (tempFile != null && tempFile.exists() && !tempFile.delete()) { + log.warn("[software-version] 清理临时文件失败 path={}", tempFile.getAbsolutePath()); + } + } + } + + private long elapsedMs(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000L; + } + + /** 将版本号转换为安全的 OSS 对象名片段:保留中文/字母/数字/._-,其余替换为 _,空则 unknown(对齐 Flask _safe_version_key)。 */ + private String safeVersionKey(String version) { + String replaced = UNSAFE_KEY_CHARS.matcher(version).replaceAll("_"); + return replaced.isEmpty() ? "unknown" : replaced; + } +}