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:
2026-09-04 10:21:38 +08:00
parent bc5d5b3d2e
commit 64e4c19531
19 changed files with 2057 additions and 0 deletions
@@ -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 + " 时间格式无效");
}
}
@@ -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 &gt;= 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 &gt;= #{submittedFrom}</if>
<if test="submittedTo != null"> AND t.submitted_at &lt;= #{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 &gt;= 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 &gt;= #{submittedFrom}</if>
<if test="submittedTo != null"> AND t.submitted_at &lt;= #{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 &gt;= 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 &gt;= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND t.id = #{taskId}
LIMIT 1
""")
Map<String, Object> selectAdminTaskDetail(@Param("taskId") Long taskId);
}
@@ -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 原始 JSONrequest 脱敏)。
*/
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.mode1 图生视频、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) {
}
}
@@ -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 仅表示数据库保存的直接授权,所有角色都不包含递归或虚拟权限")
@@ -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;
}
@@ -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;
}
@@ -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);
@@ -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);
}
}
}
@@ -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();
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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));
}
}
}
@@ -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);
}
}
@@ -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> {
}
@@ -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;
}
@@ -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 上传 MinIOkeynanri-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;
}
}