feat(admin): Java 补齐后台缺失能力(软件版本/图生视频管理/店铺记录列表)与菜单排序
将 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 等菜单权限逻辑(本迁移一环)。
This commit is contained in:
+189
@@ -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* 本地实现)。
|
||||
*
|
||||
* <p>路径与入参为管理后台前端契约,字段名保持 snake_case。</p>
|
||||
*/
|
||||
@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<Map<String, Object>> 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<Map<String, Object>> 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<StreamingResponseBody> downloadZip(@RequestBody(required = false) Map<String, Object> body,
|
||||
HttpServletRequest request) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
List<VideoSelection> 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<VideoSelection> parseSelections(Map<String, Object> 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<String> seen = new LinkedHashSet<>();
|
||||
List<VideoSelection> 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 + " 时间格式无效");
|
||||
}
|
||||
}
|
||||
+117
@@ -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("""
|
||||
<script>
|
||||
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)
|
||||
<if test="userId != null"> AND t.user_id = #{userId}</if>
|
||||
<if test="username != null and username != ''"> AND u.username LIKE CONCAT('%', #{username}, '%')</if>
|
||||
<if test="status != null and status != ''"> AND t.status = #{status}</if>
|
||||
<if test="executeId != null and executeId != ''"> AND t.coze_execute_id LIKE CONCAT('%', #{executeId}, '%')</if>
|
||||
<if test="submittedFrom != null"> AND t.submitted_at >= #{submittedFrom}</if>
|
||||
<if test="submittedTo != null"> AND t.submitted_at <= #{submittedTo}</if>
|
||||
ORDER BY t.submitted_at DESC, t.id DESC
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> 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("""
|
||||
<script>
|
||||
SELECT COUNT(*) AS total
|
||||
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)
|
||||
<if test="userId != null"> AND t.user_id = #{userId}</if>
|
||||
<if test="username != null and username != ''"> AND u.username LIKE CONCAT('%', #{username}, '%')</if>
|
||||
<if test="status != null and status != ''"> AND t.status = #{status}</if>
|
||||
<if test="executeId != null and executeId != ''"> AND t.coze_execute_id LIKE CONCAT('%', #{executeId}, '%')</if>
|
||||
<if test="submittedFrom != null"> AND t.submitted_at >= #{submittedFrom}</if>
|
||||
<if test="submittedTo != null"> AND t.submitted_at <= #{submittedTo}</if>
|
||||
</script>
|
||||
""")
|
||||
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("""
|
||||
<script>
|
||||
SELECT t.id, t.video_urls_json, t.archived_videos_json
|
||||
FROM biz_image_video_async_task t
|
||||
WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW'
|
||||
AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
|
||||
AND t.id IN
|
||||
<foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
|
||||
#{taskId}
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> selectAdminZipRows(@Param("taskIds") List<Long> 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<String, Object> selectAdminTaskDetail(@Param("taskId") Long taskId);
|
||||
}
|
||||
+453
@@ -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 实现)。
|
||||
*
|
||||
* <p>对外 JSON 一律输出 snake_case 字段名,与 Flask 管理页契约一致。</p>
|
||||
*/
|
||||
@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<String> 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<String> 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<String, Object> page(Long userId, String username, String status, String executeId,
|
||||
LocalDateTime submittedFrom, LocalDateTime submittedTo,
|
||||
int page, int pageSize) {
|
||||
int offset = (page - 1) * pageSize;
|
||||
List<Map<String, Object>> rows = adminTaskMapper.selectAdminTaskPage(
|
||||
userId, username, status, executeId, submittedFrom, submittedTo, pageSize, offset);
|
||||
Long total = adminTaskMapper.countAdminTaskPage(
|
||||
userId, username, status, executeId, submittedFrom, submittedTo);
|
||||
List<Map<String, Object>> items = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
items.add(toListItem(row, false));
|
||||
}
|
||||
Map<String, Object> 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<String, Object> detail(Long taskId) {
|
||||
Map<String, Object> row = adminTaskMapper.selectAdminTaskDetail(taskId);
|
||||
if (row == null) {
|
||||
throw new BusinessException(404, "视频任务不存在或无权访问");
|
||||
}
|
||||
return Map.of("item", toListItem(row, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量打包下载选中的视频。逐文件远程拉取写入 zip,部分失败时记录错误清单文件。
|
||||
*/
|
||||
public ZipBundle buildZip(List<VideoSelection> selections) {
|
||||
List<Long> taskIds = selections.stream()
|
||||
.map(VideoSelection::taskId)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Map<String, Object>> tasks = new HashMap<>();
|
||||
for (Map<String, Object> row : adminTaskMapper.selectAdminZipRows(taskIds)) {
|
||||
if (row.get("id") instanceof Number id) {
|
||||
tasks.put(id.longValue(), row);
|
||||
}
|
||||
}
|
||||
|
||||
Path zipFile = null;
|
||||
List<String> 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<String, Object> row = tasks.get(selection.taskId());
|
||||
List<Map<String, Object>> 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<String, Object> toListItem(Map<String, Object> row, boolean includeJson) {
|
||||
List<Map<String, Object>> videos = buildVideos(row);
|
||||
Map<String, Object> 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<String, Object>());
|
||||
item.put("request", maskSecrets(request));
|
||||
item.put("submit_response", parseJsonLoose(stringOf(row.get("submit_response_json")),
|
||||
new LinkedHashMap<String, Object>()));
|
||||
item.put("result", parseJsonLoose(stringOf(row.get("result_json")),
|
||||
new LinkedHashMap<String, Object>()));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装视频数组:以 video_urls_json 顺序为基准,从 archived_videos_json 补齐归档信息。
|
||||
*/
|
||||
private List<Map<String, Object>> buildVideos(Map<String, Object> row) {
|
||||
List<String> originals = parseStringList(row.get("video_urls_json"));
|
||||
List<Map<String, Object>> archived = parseArchivedList(row.get("archived_videos_json"));
|
||||
Map<String, Map<String, Object>> archivedBySource = new LinkedHashMap<>();
|
||||
for (Map<String, Object> stored : archived) {
|
||||
archivedBySource.put(String.valueOf(stored.getOrDefault("sourceUrl", "")), stored);
|
||||
}
|
||||
List<Map<String, Object>> items = new ArrayList<>(originals.size());
|
||||
for (String sourceUrl : originals) {
|
||||
Map<String, Object> stored = archivedBySource.getOrDefault(sourceUrl, Map.of());
|
||||
Map<String, Object> 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, Object>());
|
||||
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<String, Object> 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<Object> 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<Object>() {
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseStringList(Object raw) {
|
||||
String json = stringOf(raw);
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
List<String> parsed = objectMapper.readValue(json, new TypeReference<List<String>>() {
|
||||
});
|
||||
return parsed == null ? new ArrayList<>() : parsed;
|
||||
} catch (Exception ex) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> parseArchivedList(Object raw) {
|
||||
String json = stringOf(raw);
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> parsed = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
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) {
|
||||
}
|
||||
}
|
||||
+30
@@ -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<Void> reorderColumns(HttpServletRequest httpRequest,
|
||||
@RequestBody(required = false) Map<String, Object> 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<Long> 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 仅表示数据库保存的直接授权,所有角色都不包含递归或虚拟权限")
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+73
@@ -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<Long> orderedIds) {
|
||||
if (orderedIds == null || orderedIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> 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<PermissionMenuEntity> siblings = permissionMenuMapper.selectList(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.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<Long, PermissionMenuEntity> remaining = siblings.stream()
|
||||
.filter(s -> s.getId() != null)
|
||||
.collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity(), (a, b) -> a));
|
||||
List<PermissionMenuEntity> 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<PermissionMenuEntity>()
|
||||
.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<String> columnKeys) {
|
||||
if (operator == null || operator.getId() == null || columnKeys == null || columnKeys.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin(operator)) {
|
||||
return true;
|
||||
}
|
||||
List<PermissionMenuEntity> menus = loadMenus(MENU_TYPE_ADMIN);
|
||||
List<Long> 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<Long> 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);
|
||||
|
||||
+324
@@ -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;
|
||||
|
||||
/**
|
||||
* 店铺数据抓取记录管理端(原生端点):
|
||||
* <ul>
|
||||
* <li>GET /api/admin/shop-data-crawl-tasks 按店铺分组的分页列表(每店最新结果文件)</li>
|
||||
* <li>POST /api/admin/shop-data-crawl-tasks/download-zip 批量打包选中结果文件(部分失败经响应头提示)</li>
|
||||
* </ul>
|
||||
* 契约对齐 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<Object> 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<String, Object> 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<String, Object> body) {
|
||||
requireShopDataCrawlTaskAccess(request);
|
||||
List<Long> resultIds = parseResultIds(body);
|
||||
List<ShopDataCrawlDownloadRowDto> rows = adminTasksService.loadDownloadRows(resultIds);
|
||||
Map<Long, ShopDataCrawlDownloadRowDto> rowById = new HashMap<>();
|
||||
for (ShopDataCrawlDownloadRowDto row : rows) {
|
||||
rowById.put(row.getResultId(), row);
|
||||
}
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
Set<String> 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<String> 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<Long> parseResultIds(Map<String, Object> 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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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。
|
||||
*
|
||||
* <p>参数统一走单参 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<String, Object> p);
|
||||
|
||||
/** 当前页店铺分组(每店最新归档时间)。 */
|
||||
@SelectProvider(type = SqlProvider.class, method = "selectShopGroupPage")
|
||||
List<ShopDataCrawlAdminGroupRow> selectShopGroupPage(Map<String, Object> p);
|
||||
|
||||
/** 当前页每家店铺的最新结果行(窗口函数 row_number=1),需先铺平 shopNames。 */
|
||||
@SelectProvider(type = SqlProvider.class, method = "selectLatestRowsForShops")
|
||||
List<ShopDataCrawlAdminRow> selectLatestRowsForShops(Map<String, Object> p);
|
||||
|
||||
/** 店铺 → 分组名映射,需先铺平 shopNames。 */
|
||||
@SelectProvider(type = SqlProvider.class, method = "selectGroupLabels")
|
||||
List<ShopDataCrawlAdminGroupLabelDto> selectGroupLabels(Map<String, Object> p);
|
||||
|
||||
/** 每个结果文件最新一条 ASSEMBLE_RESULT 任务(按 job id 取最大),需先铺平 resultIds。 */
|
||||
@SelectProvider(type = SqlProvider.class, method = "selectLatestAssembleJobs")
|
||||
List<ShopDataCrawlAdminFileJobBriefDto> selectLatestAssembleJobs(Map<String, Object> p);
|
||||
|
||||
/** 批量下载 zip 所需结果行,需先铺平 resultIds。 */
|
||||
@SelectProvider(type = SqlProvider.class, method = "selectDownloadRows")
|
||||
List<ShopDataCrawlDownloadRowDto> selectDownloadRows(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+333
@@ -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<String> SUPPORTED_COUNTRIES = Set.of("DE", "UK", "FR", "IT", "ES");
|
||||
|
||||
private final ShopDataCrawlAdminTasksMapper adminTasksMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 分页列表组装(组总数 → 当前页店铺组 → 每店最新结果行 + 文件任务/分组名补充)。
|
||||
*/
|
||||
public Map<String, Object> 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<String, Object> 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<Map<String, Object>> items = new ArrayList<>();
|
||||
if (total > 0) {
|
||||
p.put("pageLimit", pageSize);
|
||||
p.put("pageOffset", (page - 1) * pageSize);
|
||||
List<ShopDataCrawlAdminGroupRow> groups = adminTasksMapper.selectShopGroupPage(p);
|
||||
if (!groups.isEmpty()) {
|
||||
items = assembleGroupItems(p, groups);
|
||||
}
|
||||
}
|
||||
Map<String, Object> 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<ShopDataCrawlDownloadRowDto> loadDownloadRows(List<Long> resultIds) {
|
||||
if (resultIds == null || resultIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
flattenList(p, resultIds, "resultIds", "ri");
|
||||
return adminTasksMapper.selectDownloadRows(p);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> assembleGroupItems(Map<String, Object> p, List<ShopDataCrawlAdminGroupRow> groups) {
|
||||
List<String> shopNames = new ArrayList<>();
|
||||
for (ShopDataCrawlAdminGroupRow group : groups) {
|
||||
shopNames.add(group.getShopName() == null ? "" : group.getShopName());
|
||||
}
|
||||
flattenList(p, shopNames, "shopNames", "sn");
|
||||
|
||||
// 分组名:店铺 → 分组(group_name 优先,多分组连接)
|
||||
Map<String, String> labelByShop = new HashMap<>();
|
||||
for (ShopDataCrawlAdminGroupLabelDto label : adminTasksMapper.selectGroupLabels(p)) {
|
||||
labelByShop.put(normalizeShopKey(label.getShopName()), label.getGroupName() == null ? "" : label.getGroupName());
|
||||
}
|
||||
|
||||
// 当前页每家店铺最新结果行,按店铺归一化名聚组
|
||||
List<ShopDataCrawlAdminRow> rows = adminTasksMapper.selectLatestRowsForShops(p);
|
||||
Map<String, List<ShopDataCrawlAdminRow>> rowsByShop = new LinkedHashMap<>();
|
||||
for (ShopDataCrawlAdminRow row : rows) {
|
||||
rowsByShop.computeIfAbsent(normalizeShopKey(row.getShopName()), key -> new ArrayList<>()).add(row);
|
||||
}
|
||||
|
||||
// 结果文件最近一次 ASSEMBLE_RESULT 任务(文件组装状态/错误)
|
||||
Map<Long, ShopDataCrawlAdminFileJobBriefDto> jobByResult = loadAssembleJobMap(rows);
|
||||
|
||||
List<Map<String, Object>> items = new ArrayList<>(groups.size());
|
||||
for (ShopDataCrawlAdminGroupRow group : groups) {
|
||||
List<ShopDataCrawlAdminRow> children = rowsByShop.getOrDefault(
|
||||
normalizeShopKey(group.getShopName()), List.of());
|
||||
List<Map<String, Object>> 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<String, Object> 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<Long, ShopDataCrawlAdminFileJobBriefDto> loadAssembleJobMap(List<ShopDataCrawlAdminRow> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> resultIds = new ArrayList<>();
|
||||
for (ShopDataCrawlAdminRow row : rows) {
|
||||
if (row.getResultId() != null && row.getResultId() > 0) {
|
||||
resultIds.add(row.getResultId());
|
||||
}
|
||||
}
|
||||
List<Long> distinctIds = new ArrayList<>(new LinkedHashSet<>(resultIds));
|
||||
if (distinctIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
flattenList(p, distinctIds, "resultIds", "ri");
|
||||
Map<Long, ShopDataCrawlAdminFileJobBriefDto> 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<String, Object> toItemMap(ShopDataCrawlAdminRow row,
|
||||
ShopDataCrawlAdminFileJobBriefDto job,
|
||||
Map<String, String> labelByShop) {
|
||||
boolean fileReady = row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank();
|
||||
Map<String, Object> 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<String, String> 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<String> countryCodesOf(String countryCodesJson, String requestJson) {
|
||||
List<String> codes = parseStringArray(countryCodesJson);
|
||||
if (codes.isEmpty()) {
|
||||
codes = requestCountryCodes(requestJson);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
private List<String> 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<String> 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<String> parseArrayNodes(JsonNode array) {
|
||||
List<String> 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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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 本地实现)。
|
||||
* <p>读写旧表 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> result = softwareVersionService.uploadSoftwareVersion(version, file);
|
||||
return ApiResponse.success("上传成功", result);
|
||||
}
|
||||
}
|
||||
+9
@@ -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<SoftwareVersionEntity> {
|
||||
}
|
||||
+29
@@ -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 时代共用同一张表)。
|
||||
* <p>注意:本表不是"数字人版本"(数字人走 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;
|
||||
}
|
||||
+149
@@ -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<Map<String, Object>> listSoftwareVersions() {
|
||||
List<SoftwareVersionEntity> entities = softwareVersionMapper.selectList(
|
||||
new LambdaQueryWrapper<SoftwareVersionEntity>()
|
||||
.orderByDesc(SoftwareVersionEntity::getCreatedAt)
|
||||
.orderByDesc(SoftwareVersionEntity::getId));
|
||||
log.info("[software-version] 查询客户端软件版本列表 count={}", entities.size());
|
||||
List<Map<String, Object>> items = new ArrayList<>(entities.size());
|
||||
for (SoftwareVersionEntity entity : entities) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user