Compare commits
4 Commits
1474f69c73
...
3420c72c1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3420c72c1d | |||
| 64e4c19531 | |||
| bc5d5b3d2e | |||
| e5909c9977 |
@@ -0,0 +1,101 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
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.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* /api/admin/** 兜底鉴权过滤器。
|
||||
*
|
||||
* <p>SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级
|
||||
* require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入 /api/admin 的请求
|
||||
* 必须具备"有效身份"(Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用
|
||||
* AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。
|
||||
*
|
||||
* <p>只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可
|
||||
* 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。
|
||||
*
|
||||
* <p>豁免项:OPTIONS 预检;以及 aiimage.security.admin-guard-exempt-prefixes 配置的前缀
|
||||
* (逗号分隔,用于内部自动化/设备回调等确需匿名可达的端点)。紧急回退:
|
||||
* aiimage.security.admin-guard-enabled=false 关闭本过滤器。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String ADMIN_API_PREFIX = "/api/admin";
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${aiimage.security.admin-guard-enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${aiimage.security.admin-guard-exempt-prefixes:}")
|
||||
private String exemptPrefixes;
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
String uri = request.getRequestURI();
|
||||
if (!(uri.equals(ADMIN_API_PREFIX) || uri.startsWith(ADMIN_API_PREFIX + "/"))) {
|
||||
return true;
|
||||
}
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : exemptSet()) {
|
||||
if (uri.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
try {
|
||||
adminAuthSupport.requireUserOrInternal(request);
|
||||
} catch (BusinessException ex) {
|
||||
ApiResponse<Void> body = ex.getCode() == null
|
||||
? ApiResponse.fail(ex.getMessage())
|
||||
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||
log.warn("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(body));
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private Set<String> exemptSet() {
|
||||
if (exemptPrefixes == null || exemptPrefixes.isBlank()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return Arrays.stream(exemptPrefixes.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public class OssProperties {
|
||||
private String imageVideoBucket;
|
||||
private String digitalHumanBucket;
|
||||
private String templateBucket;
|
||||
/** 桌面客户端软件安装包桶(历史存公开 client 桶,勿用默认私桶)。 */
|
||||
private String softwareVersionBucket;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.nanri.aiimage.modules.admin.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.permission.model.vo.PermissionMenuItemVo;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理后台页面与"当前管理员"自描述接口(Java 收敛自 Flask 侧 web_source/admin.html 的依赖)。
|
||||
*
|
||||
* <p>页面经静态资源托管于同源(classpath:/static 下的 admin.html / login.html),
|
||||
* 登录/登出沿用 auth 模块(POST /login、POST /logout)。这里提供 GET 页面转发与
|
||||
* /api/admin/current-user、/current-user/menus,保持与旧前端契约一致({item}、{items})。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "管理后台页面", description = "管理后台静态页路由与当前管理员信息")
|
||||
public class AdminConsoleController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
|
||||
@GetMapping("/")
|
||||
@Operation(summary = "根路径跳管理后台", hidden = true)
|
||||
public String root() {
|
||||
return "redirect:/admin.html";
|
||||
}
|
||||
|
||||
@GetMapping("/admin")
|
||||
@Operation(summary = "管理后台入口", hidden = true)
|
||||
public String adminPage() {
|
||||
return "redirect:/admin.html";
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
@Operation(summary = "登录页入口", hidden = true)
|
||||
public String loginPage() {
|
||||
return "forward:/login.html";
|
||||
}
|
||||
|
||||
@GetMapping("/logout")
|
||||
@Operation(summary = "登出 GET 兜底(POST 走 auth 模块)", hidden = true)
|
||||
public String logoutPage() {
|
||||
return "redirect:/login.html";
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/current-user")
|
||||
@Operation(summary = "当前登录管理员概要")
|
||||
public ApiResponse<Map<String, Object>> currentUser(HttpServletRequest request) {
|
||||
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", operator.getId());
|
||||
item.put("username", operator.getUsername() == null ? "" : operator.getUsername());
|
||||
item.put("role", adminAuthSupport.currentRole(operator));
|
||||
return ApiResponse.success(Map.of("item", item));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/current-user/menus")
|
||||
@Operation(summary = "当前登录管理员的可见菜单(扁平 route_path/name 列表)")
|
||||
public ApiResponse<Map<String, Object>> currentUserMenus(HttpServletRequest request) {
|
||||
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||
List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions(
|
||||
operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN);
|
||||
List<Map<String, String>> items = new ArrayList<>(menus.size());
|
||||
for (PermissionMenuItemVo menu : menus) {
|
||||
if (menu.getRoutePath() == null || menu.getRoutePath().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
item.put("route_path", menu.getRoutePath());
|
||||
item.put("name", menu.getName() == null ? menu.getRoutePath() : menu.getName());
|
||||
items.add(item);
|
||||
}
|
||||
return ApiResponse.success(Map.of("items", items));
|
||||
}
|
||||
}
|
||||
+57
@@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
@@ -29,6 +30,7 @@ public class OssStorageService {
|
||||
|
||||
private static final String IMAGE_VIDEO_MODULE = "IMAGE_VIDEO";
|
||||
private static final String DIGITAL_HUMAN_PREFIX = "digital-human/versions/";
|
||||
private static final String SOFTWARE_VERSION_PREFIX = "nanri-image/versions/";
|
||||
private static final String LEGACY_MINIO_ENDPOINT = "http://47.110.241.161:9000";
|
||||
|
||||
private final OssProperties ossProperties;
|
||||
@@ -77,6 +79,26 @@ public class OssStorageService {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传桌面客户端软件安装包(web_config 关联的 zip):写入**公开 client 桶**的
|
||||
* {@code nanri-image/versions/} 前缀(对齐 Flask 历史对象布局与公开直链,
|
||||
* 勿用默认私有桶 nanri-ai-images,否则桌面端"检测更新"无法匿名下载)。
|
||||
* 桶可用 aiimage.oss.software-version-bucket(env AIIMAGE_OSS_SOFTWARE_VERSION_BUCKET)覆盖。
|
||||
*/
|
||||
public String uploadSoftwareVersionPackage(File file, String objectKey) {
|
||||
if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) {
|
||||
throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX);
|
||||
}
|
||||
String bucket = softwareVersionBucket();
|
||||
uploadFile(file, bucket, objectKey);
|
||||
return getPublicUrl(objectKey, bucket);
|
||||
}
|
||||
|
||||
private String softwareVersionBucket() {
|
||||
String configured = ossProperties.getSoftwareVersionBucket();
|
||||
return (configured == null || configured.isBlank()) ? "client" : configured;
|
||||
}
|
||||
|
||||
public String uploadText(String objectKey, String content) {
|
||||
if (objectKey == null || objectKey.isBlank()) {
|
||||
throw new IllegalArgumentException("objectKey must not be blank");
|
||||
@@ -159,6 +181,41 @@ public class OssStorageService {
|
||||
return readObjectBytes(location.bucket(), location.objectKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a managed result object with a hard cap on the total bytes read.
|
||||
* Streaming 1 MiB chunks, aborting once {@code maxBytes} is exceeded.
|
||||
* The caller is expected to translate the value result_file_size.
|
||||
*/
|
||||
public byte[] readObjectBytesBounded(String value, long maxBytes) {
|
||||
StorageLocation location = resolveStorageLocation(value);
|
||||
if (location == null) {
|
||||
throw new IllegalArgumentException("object value must not be blank");
|
||||
}
|
||||
String bucket = location.bucket();
|
||||
String objectKey = location.objectKey();
|
||||
try (var stream = buildClient().getObject(GetObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectKey)
|
||||
.build())) {
|
||||
byte[] buffer = new byte[1024 * 1024];
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = stream.read(buffer)) != -1) {
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new IllegalArgumentException("结果文件过大,无法分析");
|
||||
}
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
return out.toByteArray();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw storageFailure("read", objectKey, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean objectExists(String bucket, String objectKey) {
|
||||
String normalizedBucket = requireStorageName(bucket, "bucket");
|
||||
String normalizedObjectKey = requireStorageName(objectKey, "objectKey");
|
||||
|
||||
+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);
|
||||
|
||||
+30
-8
@@ -2,12 +2,14 @@ package com.nanri.aiimage.modules.productcategory.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
|
||||
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -35,34 +37,45 @@ public class ProductCategoryController {
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
|
||||
private final ProductCategoryService productCategoryService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping("/product-categories")
|
||||
@Operation(summary = "查询商品类目树")
|
||||
public ApiResponse<ProductCategoryListVo> list(@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
public ApiResponse<ProductCategoryListVo> list(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(productCategoryService.list(keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/product-categories/children")
|
||||
@Operation(summary = "分页查询指定父级下的商品类目")
|
||||
public ApiResponse<ProductCategoryListVo> children(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "parentId", required = false) Long parentId,
|
||||
@RequestParam(value = "page", defaultValue = "1") Long page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/product-categories/search")
|
||||
@Operation(summary = "分页搜索商品类目")
|
||||
public ApiResponse<ProductCategoryListVo> search(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", defaultValue = "1") Long page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/product-categories/export")
|
||||
@Operation(summary = "导出商品类目")
|
||||
public ResponseEntity<byte[]> export(@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
public ResponseEntity<byte[]> export(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
byte[] bytes = productCategoryService.export(keyword);
|
||||
String filename = "product-categories-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
@@ -74,20 +87,29 @@ public class ProductCategoryController {
|
||||
|
||||
@PostMapping("/product-category")
|
||||
@Operation(summary = "新增商品类目")
|
||||
public ApiResponse<ProductCategoryItemVo> create(@Valid @RequestBody ProductCategorySaveRequest request) {
|
||||
return ApiResponse.success("创建成功", productCategoryService.create(request));
|
||||
public ApiResponse<ProductCategoryItemVo> create(
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody ProductCategorySaveRequest body) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success("创建成功", productCategoryService.create(body));
|
||||
}
|
||||
|
||||
@PutMapping("/product-category/{id}")
|
||||
@Operation(summary = "更新商品类目")
|
||||
public ApiResponse<ProductCategoryItemVo> update(@PathVariable Long id,
|
||||
@Valid @RequestBody ProductCategorySaveRequest request) {
|
||||
return ApiResponse.success("保存成功", productCategoryService.update(id, request));
|
||||
public ApiResponse<ProductCategoryItemVo> update(
|
||||
HttpServletRequest request,
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody ProductCategorySaveRequest body) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success("保存成功", productCategoryService.update(id, body));
|
||||
}
|
||||
|
||||
@DeleteMapping("/product-category/{id}")
|
||||
@Operation(summary = "删除商品类目")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
public ApiResponse<Void> delete(
|
||||
HttpServletRequest request,
|
||||
@PathVariable Long id) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
productCategoryService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-33
@@ -2,6 +2,8 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
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.shopkey.model.dto.QueryAsinCountryUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
|
||||
@@ -12,6 +14,7 @@ import com.nanri.aiimage.modules.shopkey.service.QueryAsinService;
|
||||
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 jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -40,18 +43,24 @@ public class QueryAsinController {
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
|
||||
private final QueryAsinService queryAsinService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
/** 解析当前操作者(JWT 或内部令牌通道),权限与数据范围一律以该身份为准。 */
|
||||
private AdminUserEntity requireOperator(HttpServletRequest request) {
|
||||
return adminAuthSupport.requireAdminOrInternal(request);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询 ASIN")
|
||||
public ApiResponse<QueryAsinPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success(queryAsinService.page(
|
||||
page,
|
||||
pageSize,
|
||||
@@ -59,20 +68,21 @@ public class QueryAsinController {
|
||||
shopName,
|
||||
asin,
|
||||
country,
|
||||
operatorId,
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
operator.getId(),
|
||||
isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出查询 ASIN")
|
||||
public ResponseEntity<byte[]> export(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
byte[] bytes = queryAsinService.export(groupId, shopName, asin, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
byte[] bytes = queryAsinService.export(groupId, shopName, asin, country,
|
||||
operator.getId(), isSuper(operator));
|
||||
String filename = "query-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
|
||||
@@ -84,71 +94,81 @@ public class QueryAsinController {
|
||||
@PostMapping
|
||||
@Operation(summary = "新增或覆盖查询 ASIN")
|
||||
public ApiResponse<QueryAsinItemVo> create(
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody QueryAsinCreateRequest request) {
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody QueryAsinCreateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("保存成功",
|
||||
queryAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
queryAsinService.createOrUpdate(body, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入添加查询 ASIN")
|
||||
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("开始导入",
|
||||
queryAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
queryAsinService.startImport(file, groupId, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "查询导入添加进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) {
|
||||
requireOperator(request);
|
||||
return ApiResponse.success(queryAsinService.getImportProgress(importId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@Operation(summary = "导入删除查询 ASIN")
|
||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("开始删除",
|
||||
queryAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
queryAsinService.startDeleteImport(file, groupId, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "查询导入删除进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) {
|
||||
requireOperator(request);
|
||||
return ApiResponse.success(queryAsinService.getDeleteImportProgress(importId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "编辑指定国家的查询 ASIN")
|
||||
public ApiResponse<QueryAsinItemVo> updateCountry(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody QueryAsinCountryUpdateRequest request) {
|
||||
@Valid @RequestBody QueryAsinCountryUpdateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("保存成功", queryAsinService.updateCountry(
|
||||
id,
|
||||
country,
|
||||
request.getAsin(),
|
||||
operatorId,
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
body.getAsin(),
|
||||
operator.getId(),
|
||||
isSuper(operator)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "删除指定国家的查询 ASIN")
|
||||
public ApiResponse<Void> deleteCountry(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
queryAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
@Parameter(description = "国家编码", required = true) @PathVariable String country) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
queryAsinService.deleteCountry(id, country, operator.getId(), isSuper(operator));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
private boolean isSuper(AdminUserEntity operator) {
|
||||
return "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-5
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
|
||||
@@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -31,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class ShopKeyController {
|
||||
|
||||
private final ShopKeyService shopKeyService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询店铺密钥", description = "分页查询店铺密钥列表。")
|
||||
@@ -38,8 +41,10 @@ public class ShopKeyController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopKeyPageVo.class)))
|
||||
})
|
||||
public ApiResponse<ShopKeyPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(shopKeyService.page(page, pageSize));
|
||||
}
|
||||
|
||||
@@ -49,8 +54,11 @@ public class ShopKeyController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法")
|
||||
})
|
||||
public ApiResponse<ShopKeyItemVo> create(@Valid @RequestBody ShopKeyCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", shopKeyService.create(request));
|
||||
public ApiResponse<ShopKeyItemVo> create(
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody ShopKeyCreateRequest body) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success("创建成功", shopKeyService.create(body));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@@ -61,9 +69,11 @@ public class ShopKeyController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在")
|
||||
})
|
||||
public ApiResponse<ShopKeyItemVo> update(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Valid @RequestBody ShopKeyUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", shopKeyService.update(id, request));
|
||||
@Valid @RequestBody ShopKeyUpdateRequest body) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success("更新成功", shopKeyService.update(id, body));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -72,7 +82,10 @@ public class ShopKeyController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在")
|
||||
})
|
||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
public ApiResponse<Void> delete(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
shopKeyService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
+60
-37
@@ -1,6 +1,8 @@
|
||||
package com.nanri.aiimage.modules.shopkey.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.shopkey.model.dto.ShopManageCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
|
||||
@@ -17,6 +19,7 @@ import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -37,11 +40,18 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Tag(name = "店铺管理", description = "维护店铺信息,支持查询、新建、修改、删除")
|
||||
public class ShopManageController {
|
||||
|
||||
@Value("${aiimage.security.internal-token:}")
|
||||
private String internalToken;
|
||||
|
||||
private final ShopManageService shopManageService;
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
/** 解析当前操作者(JWT 或内部令牌通道),数据范围一律以该身份为准,不信任调用方传入的 operatorId/superAdmin。 */
|
||||
private AdminUserEntity requireOperator(HttpServletRequest request) {
|
||||
return adminAuthSupport.requireAdminOrInternal(request);
|
||||
}
|
||||
|
||||
private boolean isSuper(AdminUserEntity operator) {
|
||||
return "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名、当前操作者可见范围筛选")
|
||||
@@ -52,92 +62,92 @@ public class ShopManageController {
|
||||
content = @Content(schema = @Schema(implementation = ShopManagePageVo.class)))
|
||||
})
|
||||
public ApiResponse<ShopManagePageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success(shopManageService.page(
|
||||
page,
|
||||
pageSize,
|
||||
groupId,
|
||||
shopName,
|
||||
operatorId,
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
operator.getId(),
|
||||
isSuper(operator)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
|
||||
public ApiResponse<ShopManageItemVo> create(
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageCreateRequest request) {
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody ShopManageCreateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("创建成功",
|
||||
shopManageService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
shopManageService.create(body, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新店铺", description = "按 ID 更新店铺")
|
||||
public ApiResponse<ShopManageItemVo> update(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageUpdateRequest request) {
|
||||
@Valid @RequestBody ShopManageUpdateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("更新成功",
|
||||
shopManageService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
|
||||
shopManageService.update(id, operator.getId(), isSuper(operator), body));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除店铺", description = "按 ID 删除店铺")
|
||||
public ApiResponse<Void> delete(
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
shopManageService.delete(id, operator.getId(), isSuper(operator));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@GetMapping("/groups")
|
||||
@Operation(summary = "查询分组列表", description = "普通管理员返回当前可访问的分组,超级管理员返回全部")
|
||||
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
|
||||
@Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
if (Boolean.TRUE.equals(superAdmin)) {
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
if (isSuper(operator)) {
|
||||
return ApiResponse.success(shopManageGroupService.list(createdById));
|
||||
}
|
||||
return ApiResponse.success(shopManageGroupService.listAccessible(operatorId, false));
|
||||
return ApiResponse.success(shopManageGroupService.listAccessible(operator.getId(), false));
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
|
||||
public ApiResponse<ShopManageGroupItemVo> createGroup(
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageGroupCreateRequest request) {
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody ShopManageGroupCreateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("创建成功",
|
||||
shopManageGroupService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
shopManageGroupService.create(body, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@PutMapping("/groups/{id}")
|
||||
@Operation(summary = "更新分组", description = "普通管理员只能修改自己可访问的分组")
|
||||
public ApiResponse<ShopManageGroupItemVo> updateGroup(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
|
||||
@Valid @RequestBody ShopManageGroupUpdateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("更新成功",
|
||||
shopManageGroupService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
|
||||
shopManageGroupService.update(id, operator.getId(), isSuper(operator), body));
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{id}")
|
||||
@Operation(summary = "删除分组", description = "普通管理员只能删除自己可访问的分组")
|
||||
public ApiResponse<Void> deleteGroup(
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
shopManageGroupService.delete(id, operator.getId(), isSuper(operator));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@@ -151,4 +161,17 @@ public class ShopManageController {
|
||||
}
|
||||
return ApiResponse.success(shopManageService.getCredentialByShopName(shopName));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/credential")
|
||||
@Operation(summary = "查看店铺明文凭据(管理端)", description = "管理员在后台查看店铺明文凭据;受管理员身份保护(建议后续补充归属/角色细粒度校验)")
|
||||
public ApiResponse<ShopManageCredentialVo> adminCredential(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "店铺主键 ID") @PathVariable Long id,
|
||||
@Parameter(description = "店铺名") @RequestParam("shop_name") String shopName) {
|
||||
requireOperator(request);
|
||||
return ApiResponse.success(shopManageService.getCredentialByShopName(shopName));
|
||||
}
|
||||
|
||||
@Value("${aiimage.security.internal-token:}")
|
||||
private String internalToken;
|
||||
}
|
||||
|
||||
+49
-33
@@ -2,6 +2,8 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
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.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
|
||||
@@ -12,6 +14,7 @@ import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
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 jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -31,7 +34,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -42,10 +44,17 @@ public class SkipPriceAsinController {
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
/** 解析当前操作者(JWT 或内部令牌通道),权限与数据范围一律以该身份为准。 */
|
||||
private AdminUserEntity requireOperator(HttpServletRequest request) {
|
||||
return adminAuthSupport.requireAdminOrInternal(request);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询跳过跟价 ASIN", description = "支持按分组、店铺名、ASIN 和当前操作人可见范围筛选。")
|
||||
public ApiResponse<SkipPriceAsinPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@@ -53,9 +62,8 @@ public class SkipPriceAsinController {
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country,
|
||||
@Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom,
|
||||
@Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success(skipPriceAsinService.page(
|
||||
page,
|
||||
pageSize,
|
||||
@@ -65,23 +73,23 @@ public class SkipPriceAsinController {
|
||||
country,
|
||||
minimumPriceFrom,
|
||||
minimumPriceTo,
|
||||
operatorId,
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
operator.getId(),
|
||||
isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出跳过跟价 ASIN", description = "按当前查询条件导出 Excel。")
|
||||
public ResponseEntity<byte[]> export(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country,
|
||||
@Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom,
|
||||
@Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, country, minimumPriceFrom, minimumPriceTo,
|
||||
operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
operator.getId(), isSuper(operator));
|
||||
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
|
||||
@@ -93,74 +101,82 @@ public class SkipPriceAsinController {
|
||||
@PostMapping
|
||||
@Operation(summary = "新增跳过跟价 ASIN", description = "新增一条跳过跟价 ASIN 记录。")
|
||||
public ApiResponse<SkipPriceAsinItemVo> create(
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody SkipPriceAsinCreateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("保存成功",
|
||||
skipPriceAsinService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
skipPriceAsinService.create(body, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步导入新增数据。")
|
||||
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("开始导入",
|
||||
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
skipPriceAsinService.startImport(file, groupId, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "查询导入进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) {
|
||||
requireOperator(request);
|
||||
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@Operation(summary = "导入删除跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步按文件内容删除数据。")
|
||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("开始删除",
|
||||
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
skipPriceAsinService.startDeleteImport(file, groupId, operator.getId(), isSuper(operator)));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "查询导入删除进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) {
|
||||
requireOperator(request);
|
||||
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "更新指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest body) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
|
||||
id,
|
||||
country,
|
||||
request.getAsin(),
|
||||
request.getMinimumPrice(),
|
||||
operatorId,
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
body.getAsin(),
|
||||
body.getMinimumPrice(),
|
||||
operator.getId(),
|
||||
isSuper(operator)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<Void> deleteCountry(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
@Parameter(description = "国家代码", required = true) @PathVariable String country) {
|
||||
AdminUserEntity operator = requireOperator(request);
|
||||
skipPriceAsinService.deleteCountry(id, country, operator.getId(), isSuper(operator));
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
private boolean isSuper(AdminUserEntity operator) {
|
||||
return "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ aiimage:
|
||||
image-video-bucket: ${AIIMAGE_IMAGE_VIDEO_OSS_BUCKET:shufu-video}
|
||||
digital-human-bucket: ${AIIMAGE_DIGITAL_HUMAN_OSS_BUCKET:nanri-ai-digital-human}
|
||||
template-bucket: ${AIIMAGE_TEMPLATE_OSS_BUCKET:aiimage-templates}
|
||||
software-version-bucket: ${AIIMAGE_OSS_SOFTWARE_VERSION_BUCKET:client}
|
||||
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:appuser}
|
||||
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:AppUser@2024SecureKey}
|
||||
transient-storage:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,887 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="theme-color" content="#0b1220">
|
||||
<title>登录 - 数富AI</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--auth-bg: #0b1220;
|
||||
--auth-surface: #141e31;
|
||||
--auth-surface-raised: #1a2740;
|
||||
--auth-border: rgba(148, 163, 184, 0.2);
|
||||
--auth-border-strong: rgba(148, 163, 184, 0.34);
|
||||
--auth-text: #f1f5f9;
|
||||
--auth-text-muted: #b8c4d8;
|
||||
--auth-text-subtle: #8795ad;
|
||||
--auth-primary: #818cf8;
|
||||
--auth-primary-strong: #a5b4fc;
|
||||
--auth-success: #4ade80;
|
||||
--auth-danger: #fb7185;
|
||||
--auth-radius: 18px;
|
||||
--auth-fast: 160ms;
|
||||
--auth-base: 220ms;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
background: var(--auth-bg);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
font-family: Inter, "SF Pro Display", "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--auth-text);
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(99, 102, 241, 0.19), transparent 32rem),
|
||||
radial-gradient(circle at 92% 100%, rgba(34, 197, 94, 0.06), transparent 28rem),
|
||||
var(--auth-bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input { font: inherit; }
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
.auth-skip-link {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 20;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--auth-primary);
|
||||
color: #0b1220;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transform: translateY(-160%);
|
||||
transition: transform var(--auth-fast) ease;
|
||||
}
|
||||
|
||||
.auth-skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
|
||||
}
|
||||
|
||||
.auth-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100vh;
|
||||
padding: 36px clamp(32px, 5vw, 84px) 34px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.13);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(16, 26, 46, 0.92), rgba(8, 15, 29, 0.98)),
|
||||
radial-gradient(circle at 20% 8%, rgba(129, 140, 248, 0.18), transparent 28rem);
|
||||
}
|
||||
|
||||
.auth-visual::before,
|
||||
.auth-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-visual::before {
|
||||
inset: 0;
|
||||
opacity: 0.24;
|
||||
background-image: linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
|
||||
.auth-visual::after {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
right: -150px;
|
||||
bottom: -160px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.18);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 32px rgba(129, 140, 248, 0.035), 0 0 0 64px rgba(129, 140, 248, 0.025);
|
||||
}
|
||||
|
||||
.auth-visual-content,
|
||||
.auth-visual-footer { position: relative; z-index: 1; }
|
||||
|
||||
.auth-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: fit-content;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 28px rgba(79, 70, 229, 0.20);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-brand-mark img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 24px -12px rgba(39, 67, 94, 0.45);
|
||||
}
|
||||
|
||||
.auth-brand-copy { display: grid; gap: 1px; }
|
||||
|
||||
.auth-brand-name {
|
||||
color: #f8fafc;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.auth-brand-sub {
|
||||
color: var(--auth-text-subtle);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-visual-content {
|
||||
max-width: 520px;
|
||||
margin: auto 0;
|
||||
padding: 72px 0 96px;
|
||||
}
|
||||
|
||||
.auth-visual-copy {
|
||||
padding-top: 72px;
|
||||
}
|
||||
|
||||
.auth-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--auth-primary-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
|
||||
.auth-kicker::before {
|
||||
content: "";
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--auth-primary);
|
||||
}
|
||||
|
||||
.auth-visual-title {
|
||||
max-width: 560px;
|
||||
margin: 18px 0 18px;
|
||||
color: #f8fafc;
|
||||
font-size: clamp(30px, 4vw, 52px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -1.8px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.auth-visual-description {
|
||||
max-width: 470px;
|
||||
margin: 0;
|
||||
color: var(--auth-text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.auth-feature-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 34px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #dbe4f1;
|
||||
}
|
||||
|
||||
.auth-feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.25);
|
||||
border-radius: 9px;
|
||||
background: rgba(129, 140, 248, 0.11);
|
||||
color: var(--auth-primary-strong);
|
||||
}
|
||||
|
||||
.auth-feature-icon svg { width: 16px; height: 16px; }
|
||||
|
||||
.auth-visual-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: #72819a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-system-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.auth-system-status::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--auth-success);
|
||||
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.12);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 40px clamp(24px, 6vw, 96px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 452px);
|
||||
padding: clamp(28px, 4vw, 48px);
|
||||
border: 1px solid var(--auth-border);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, rgba(20, 30, 49, 0.98), rgba(16, 26, 46, 0.96));
|
||||
box-shadow: 0 26px 70px -38px rgba(2, 6, 23, 0.95);
|
||||
}
|
||||
|
||||
.login-card-header { margin-bottom: 30px; }
|
||||
|
||||
.login-card-eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--auth-text-subtle);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
color: var(--auth-text);
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 10px 0 0;
|
||||
color: var(--auth-text-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--auth-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-shell { position: relative; }
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 14px;
|
||||
display: inline-flex;
|
||||
color: #8190a8;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.input-icon svg { width: 18px; height: 18px; }
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 52px 12px 44px;
|
||||
border: 1px solid var(--auth-border);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
background: rgba(11, 18, 32, 0.72);
|
||||
color: var(--auth-text);
|
||||
font-size: 14px;
|
||||
color-scheme: dark;
|
||||
transition: border-color var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease;
|
||||
}
|
||||
|
||||
.form-group input:hover { border-color: var(--auth-border-strong); }
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--auth-primary);
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
box-shadow: 0 0 0 4px rgba(129, 140, 248, 0.15);
|
||||
}
|
||||
|
||||
.form-group input::placeholder { color: #71809a; }
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #8190a8;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition: background var(--auth-fast) ease, color var(--auth-fast) ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
background: rgba(129, 140, 248, 0.12);
|
||||
color: var(--auth-primary-strong);
|
||||
}
|
||||
|
||||
.password-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
.error-msg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin: -4px 0 18px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(251, 113, 133, 0.28);
|
||||
border-radius: 11px;
|
||||
background: rgba(251, 113, 133, 0.1);
|
||||
color: #fda4af;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.error-msg::before {
|
||||
content: "!";
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid rgba(165, 180, 252, 0.32);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #818cf8, #6366f1);
|
||||
color: #0b1220;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 12px 24px -16px rgba(129, 140, 248, 0.95);
|
||||
transition: transform var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease, opacity var(--auth-fast) ease;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #a5b4fc, #818cf8);
|
||||
box-shadow: 0 16px 28px -15px rgba(129, 140, 248, 0.98);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-login:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
|
||||
|
||||
.btn-login:disabled { cursor: wait; opacity: 0.7; }
|
||||
|
||||
.btn-login-spinner {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(11, 18, 32, 0.28);
|
||||
border-top-color: #0b1220;
|
||||
border-radius: 50%;
|
||||
animation: login-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-login.is-loading .btn-login-spinner { display: inline-block; }
|
||||
|
||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.login-security-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin: 22px 0 0;
|
||||
color: var(--auth-text-subtle);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.login-security-note svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 1px;
|
||||
color: var(--auth-success);
|
||||
}
|
||||
|
||||
.login-card-footer {
|
||||
margin-top: 34px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.13);
|
||||
color: #72819a;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--auth-primary-strong);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-page { display: block; }
|
||||
.auth-visual {
|
||||
min-height: auto;
|
||||
padding: 22px 24px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
|
||||
}
|
||||
.auth-visual-content { display: block; max-width: none; margin: 0; padding: 0; }
|
||||
.auth-visual-copy { display: none; }
|
||||
.auth-visual-footer { display: none; }
|
||||
.auth-content { min-height: calc(100vh - 87px); padding: 32px 24px 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.auth-visual { padding: 18px 16px; }
|
||||
.auth-content { padding: 24px 14px 32px; }
|
||||
.login-card { padding: 26px 20px; border-radius: 18px; }
|
||||
.login-title { font-size: 27px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 登录页莫兰迪亮色主题 ===== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--auth-bg: #f2f4f1;
|
||||
--auth-surface: #ffffff;
|
||||
--auth-surface-raised: #f8faf8;
|
||||
--auth-border: #dbe3dd;
|
||||
--auth-border-strong: #b9c9bd;
|
||||
--auth-text: #29362f;
|
||||
--auth-text-muted: #5d6c63;
|
||||
--auth-text-subtle: #7f8d84;
|
||||
--auth-primary: #607a6d;
|
||||
--auth-primary-strong: #456052;
|
||||
--auth-success: #4e8068;
|
||||
--auth-danger: #a9545d;
|
||||
}
|
||||
|
||||
html { background: var(--auth-bg); }
|
||||
|
||||
body {
|
||||
color: var(--auth-text);
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(177, 198, 185, 0.34), transparent 34rem),
|
||||
radial-gradient(circle at 96% 100%, rgba(213, 190, 178, 0.2), transparent 28rem),
|
||||
var(--auth-bg);
|
||||
}
|
||||
|
||||
.auth-skip-link {
|
||||
background: var(--auth-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.auth-visual {
|
||||
border-right-color: #d5ded7;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(232, 239, 234, 0.96), rgba(246, 248, 245, 0.98)),
|
||||
radial-gradient(circle at 20% 8%, rgba(127, 153, 138, 0.16), transparent 28rem);
|
||||
}
|
||||
|
||||
.auth-visual::before {
|
||||
opacity: 0.32;
|
||||
background-image: linear-gradient(rgba(96, 122, 109, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96, 122, 109, 0.1) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.auth-visual::after {
|
||||
border-color: rgba(96, 122, 109, 0.22);
|
||||
box-shadow: 0 0 0 32px rgba(96, 122, 109, 0.055), 0 0 0 64px rgba(96, 122, 109, 0.035);
|
||||
}
|
||||
|
||||
.auth-brand-mark {
|
||||
border-radius: 13px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(96, 122, 109, 0.18);
|
||||
}
|
||||
|
||||
.auth-brand-name,
|
||||
.auth-visual-title { color: var(--auth-text); }
|
||||
.auth-brand-sub { color: #76857b; }
|
||||
.auth-kicker { color: var(--auth-primary-strong); }
|
||||
.auth-kicker::before { background: var(--auth-primary); }
|
||||
.auth-visual-description { color: #607069; }
|
||||
.auth-feature-item { color: #46564d; }
|
||||
.auth-feature-icon { border-color: #c5d5c9; background: #edf3ee; color: var(--auth-primary-strong); }
|
||||
.auth-visual-footer { color: #77857d; }
|
||||
.auth-system-status { color: #3f7258; }
|
||||
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
|
||||
|
||||
.auth-content {
|
||||
background: rgba(248, 250, 248, 0.45);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-color: var(--auth-border);
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbf9);
|
||||
box-shadow: 0 26px 70px -38px rgba(60, 77, 67, 0.34);
|
||||
}
|
||||
|
||||
.login-card-eyebrow { color: #718078; }
|
||||
.login-title { color: var(--auth-text); }
|
||||
.login-subtitle { color: var(--auth-text-muted); }
|
||||
.form-group label { color: var(--auth-text-muted); }
|
||||
.input-icon { color: #82938a; }
|
||||
|
||||
.form-group input {
|
||||
background: #f7faf7;
|
||||
border-color: #cfdad2;
|
||||
color: var(--auth-text);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.form-group input:hover { border-color: #aebfb3; }
|
||||
.form-group input:focus { background: #ffffff; border-color: #6f8b7b; box-shadow: 0 0 0 4px rgba(111, 139, 123, 0.16); }
|
||||
.form-group input::placeholder { color: #87958c; }
|
||||
|
||||
.password-toggle { color: #82938a; }
|
||||
.password-toggle:hover { background: #edf4ee; color: var(--auth-primary-strong); }
|
||||
|
||||
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
|
||||
|
||||
.btn-login {
|
||||
border-color: #7d9988;
|
||||
background: linear-gradient(135deg, #718b7c, #607a6d);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 12px 24px -16px rgba(96, 122, 109, 0.82);
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #829b8b, #6b8577);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 16px 28px -15px rgba(96, 122, 109, 0.86);
|
||||
}
|
||||
|
||||
.btn-login-spinner { border-color: rgba(255, 255, 255, 0.35); border-top-color: #ffffff; }
|
||||
.login-security-note { color: #718078; }
|
||||
.login-security-note svg { color: var(--auth-success); }
|
||||
.login-card-footer { border-top-color: #dce5de; color: #77857d; }
|
||||
:focus-visible { outline-color: #607a6d; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-visual { border-bottom-color: #d5ded7; }
|
||||
}
|
||||
|
||||
|
||||
/* ===== 登录页统一蓝白色调 ===== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--auth-bg: #f4f7fb;
|
||||
--auth-surface: #ffffff;
|
||||
--auth-surface-raised: #f9fbfd;
|
||||
--auth-border: #d8e3ee;
|
||||
--auth-border-strong: #b7c9db;
|
||||
--auth-text: #24384d;
|
||||
--auth-text-muted: #5b6f83;
|
||||
--auth-text-subtle: #8293a5;
|
||||
--auth-primary: #4f78a5;
|
||||
--auth-primary-strong: #2f5d8b;
|
||||
--auth-success: #4e806d;
|
||||
--auth-danger: #b35f6a;
|
||||
}
|
||||
|
||||
html, body { background: var(--auth-bg); }
|
||||
body {
|
||||
color: var(--auth-text);
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(178, 205, 229, 0.32), transparent 34rem),
|
||||
radial-gradient(circle at 96% 100%, rgba(214, 226, 239, 0.28), transparent 28rem),
|
||||
var(--auth-bg);
|
||||
}
|
||||
.auth-skip-link { background: var(--auth-primary); color: #ffffff; }
|
||||
.auth-visual {
|
||||
border-right-color: #d4e0eb;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(232, 240, 248, 0.97), rgba(248, 251, 254, 0.99)),
|
||||
radial-gradient(circle at 20% 8%, rgba(112, 148, 186, 0.16), transparent 28rem);
|
||||
}
|
||||
.auth-visual::before { opacity: 0.3; background-image: linear-gradient(rgba(79, 120, 165, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(79, 120, 165, 0.1) 1px, transparent 1px); }
|
||||
.auth-visual::after { border-color: rgba(79, 120, 165, 0.22); box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035); }
|
||||
.auth-brand-mark { border-radius: 13px; background: #ffffff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18); }
|
||||
.auth-brand-name, .auth-visual-title { color: var(--auth-text); }
|
||||
.auth-brand-sub { color: #77899b; }
|
||||
.auth-kicker { color: var(--auth-primary-strong); }
|
||||
.auth-kicker::before { background: var(--auth-primary); }
|
||||
.auth-visual-description { color: #60748a; }
|
||||
.auth-feature-item { color: #465d73; }
|
||||
.auth-feature-icon { border-color: #c2d3e3; background: #edf5fb; color: var(--auth-primary-strong); }
|
||||
.auth-visual-footer { color: #778b9f; }
|
||||
.auth-system-status { color: #3d7158; }
|
||||
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
|
||||
.auth-content { background: rgba(249, 251, 253, 0.5); }
|
||||
.login-card { border-color: var(--auth-border); background: linear-gradient(145deg, #ffffff, #f9fbfd); box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34); }
|
||||
.login-card-eyebrow { color: #71859a; }
|
||||
.login-title { color: var(--auth-text); }
|
||||
.login-subtitle, .form-group label { color: var(--auth-text-muted); }
|
||||
.input-icon, .password-toggle { color: #8298ad; }
|
||||
.form-group input { background: #f8fbfd; border-color: #cbd9e6; color: var(--auth-text); color-scheme: light; }
|
||||
.form-group input:hover { border-color: #9fb7cd; }
|
||||
.form-group input:focus { background: #ffffff; border-color: #5f85ad; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
|
||||
.form-group input::placeholder { color: #8293a5; }
|
||||
.password-toggle:hover { background: #edf5fb; color: var(--auth-primary-strong); }
|
||||
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
|
||||
.btn-login { border-color: #7196ba; background: linear-gradient(135deg, #5f85ad, #4f78a5); color: #ffffff; box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82); }
|
||||
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #ffffff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); }
|
||||
.btn-login-spinner { border-color: rgba(255,255,255,0.35); border-top-color: #ffffff; }
|
||||
.login-security-note { color: #71859a; }
|
||||
.login-security-note svg { color: var(--auth-success); }
|
||||
.login-card-footer { border-top-color: #dce5ee; color: #778b9f; }
|
||||
:focus-visible { outline-color: #4f78a5; }
|
||||
@media (max-width: 900px) { .auth-visual { border-bottom-color: #d4e0eb; } }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="auth-skip-link" href="#loginMain">跳转到登录表单</a>
|
||||
<div class="auth-page">
|
||||
<aside class="auth-visual" aria-label="数富AI产品信息">
|
||||
<div class="auth-visual-content">
|
||||
<a class="auth-brand" href="/login" aria-label="数富AI 登录页">
|
||||
<span class="auth-brand-mark" aria-hidden="true"><img src="/static/logo_thumb.png" alt=""></span>
|
||||
<span class="auth-brand-copy">
|
||||
<span class="auth-brand-name">数富AI</span>
|
||||
<span class="auth-brand-sub">电商运营管理后台</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="auth-visual-copy">
|
||||
<span class="auth-kicker">数富AI · 运营工作台</span>
|
||||
<h2 class="auth-visual-title">让每一次运营动作,都有清晰的工作流。</h2>
|
||||
<p class="auth-visual-description">统一管理数据、店铺、任务与版本,让团队在一个可靠的运营工作台中快速协作。</p>
|
||||
<ul class="auth-feature-list" aria-label="工作台能力">
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg></span>
|
||||
<span>权限隔离,操作边界清晰可控</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"></path><circle cx="12" cy="12" r="9"></circle><path d="M3 4v5h5"></path><path d="M3.5 9A9 9 0 0 1 19 5.5"></path></svg></span>
|
||||
<span>任务状态,进度反馈及时透明</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="m7 15 3-4 3 2 4-6"></path><path d="M17 7h3v3"></path></svg></span>
|
||||
<span>数据工具,支撑日常电商运营</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-visual-footer">
|
||||
<span>数富AI · 管理控制台</span>
|
||||
<span class="auth-system-status">本地服务已就绪</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="auth-content" id="loginMain">
|
||||
<section class="login-card" aria-labelledby="loginTitle">
|
||||
<header class="login-card-header">
|
||||
<img class="login-logo" src="/static/logo_thumb.png" alt="数富AI">
|
||||
<p class="login-card-eyebrow">欢迎回来</p>
|
||||
<h1 class="login-title" id="loginTitle">登录工作台</h1>
|
||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||
</header>
|
||||
<form id="loginForm" method="POST" action="/login" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">用户名</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
|
||||
<input type="text" id="loginUsername" name="username" autocomplete="username" placeholder="请输入用户名" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">密码</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="10" x="5" y="11" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
|
||||
<input type="password" id="loginPassword" name="password" autocomplete="current-password" placeholder="请输入密码" required>
|
||||
<button type="button" class="password-toggle" id="togglePassword" aria-label="显示密码" aria-pressed="false" title="显示密码">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="btnLogin" aria-busy="false">
|
||||
<span class="btn-login-label">登录</span>
|
||||
<span class="btn-login-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="login-security-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg>
|
||||
<span>请勿在公共设备保存账号凭据。登录状态由本地安全会话管理。</span>
|
||||
</p>
|
||||
<footer class="login-card-footer">数富AI · 电商运营管理工作台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('loginForm');
|
||||
var btn = document.getElementById('btnLogin');
|
||||
var label = btn ? btn.querySelector('.btn-login-label') : null;
|
||||
var password = document.getElementById('loginPassword');
|
||||
var togglePassword = document.getElementById('togglePassword');
|
||||
if (!form || !btn) return;
|
||||
|
||||
if (password && togglePassword) {
|
||||
togglePassword.addEventListener('click', function () {
|
||||
var visible = password.type === 'password';
|
||||
password.type = visible ? 'text' : 'password';
|
||||
togglePassword.setAttribute('aria-pressed', visible ? 'true' : 'false');
|
||||
togglePassword.setAttribute('aria-label', visible ? '隐藏密码' : '显示密码');
|
||||
togglePassword.setAttribute('title', visible ? '隐藏密码' : '显示密码');
|
||||
});
|
||||
}
|
||||
|
||||
function setError(message) {
|
||||
var errEl = document.getElementById('loginError') || document.querySelector('.error-msg');
|
||||
if (!errEl) {
|
||||
errEl = document.createElement('p');
|
||||
errEl.id = 'loginError';
|
||||
errEl.className = 'error-msg';
|
||||
errEl.setAttribute('role', 'alert');
|
||||
errEl.setAttribute('aria-live', 'assertive');
|
||||
form.insertBefore(errEl, form.firstChild);
|
||||
}
|
||||
errEl.textContent = message || '登录失败';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
btn.disabled = loading;
|
||||
btn.classList.toggle('is-loading', loading);
|
||||
btn.setAttribute('aria-busy', loading ? 'true' : 'false');
|
||||
if (label) label.textContent = loading ? '登录中...' : '登录';
|
||||
}
|
||||
|
||||
function deviceId() {
|
||||
try {
|
||||
var key = 'aiimage_console_device_id';
|
||||
var value = localStorage.getItem(key);
|
||||
if (!value) {
|
||||
value = 'web-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
return value;
|
||||
} catch (e) {
|
||||
return 'web-console-fallback';
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录(Java cookie 有效)时直接进入工作台。
|
||||
fetch('/check_login', { method: 'GET', credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.success) window.location.replace('/admin.html');
|
||||
})
|
||||
.catch(function () { /* 忽略,保持登录页 */ });
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
if (btn.disabled) return;
|
||||
var username = (document.getElementById('loginUsername').value || '').trim();
|
||||
var password = document.getElementById('loginPassword').value || '';
|
||||
if (!username || !password) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
var did = deviceId();
|
||||
setLoading(true);
|
||||
fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Device-Id': did },
|
||||
body: JSON.stringify({ username: username, password: password, deviceId: did }),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().catch(function () {
|
||||
return { success: false, message: '登录响应异常,请重试' };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result && result.success) {
|
||||
window.location.replace('/admin.html');
|
||||
return;
|
||||
}
|
||||
setError((result && (result.error || result.message)) || '用户名或密码错误');
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(function () {
|
||||
setError('无法连接服务,请稍后重试');
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,333 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.__adminInteractionLayerInstalled) return;
|
||||
window.__adminInteractionLayerInstalled = true;
|
||||
|
||||
var toastRegion = document.getElementById('adminToastRegion');
|
||||
var confirmMask = document.getElementById('adminConfirmModal');
|
||||
var confirmTitle = document.getElementById('adminConfirmTitle');
|
||||
var confirmMessage = document.getElementById('adminConfirmMessage');
|
||||
var confirmAccept = document.getElementById('adminConfirmAccept');
|
||||
var confirmCancel = document.getElementById('adminConfirmCancel');
|
||||
var guide = document.getElementById('adminOperationGuide');
|
||||
var guideText = document.getElementById('adminOperationGuideText');
|
||||
var guideSteps = document.getElementById('adminOperationGuideSteps');
|
||||
var guideToggle = document.getElementById('adminOperationGuideToggle');
|
||||
var pendingConfirmButton = null;
|
||||
var previousFocus = null;
|
||||
var busyButton = null;
|
||||
var busyWasDisabled = false;
|
||||
var activeFetches = 0;
|
||||
var activeXhrs = 0;
|
||||
var mainContent = document.getElementById('adminContent');
|
||||
|
||||
var guides = {
|
||||
users: { text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。', steps: ['筛选账号', '编辑权限', '确认保存'] },
|
||||
columns: { text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。', steps: ['新增或调整菜单', '设置层级', '拖动排序'] },
|
||||
'dedupe-total-data': { text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。', steps: ['选择分组', '筛选或导入', '核对并导出'] },
|
||||
'invalid-asin-data': { text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。', steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'] },
|
||||
'shop-keys': { text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。', steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'] },
|
||||
'shop-manage': { text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。', steps: ['选择分组', '维护店铺信息', '筛选核对结果'] },
|
||||
'skip-price-asin': { text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。', steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'] },
|
||||
'query-asin': { text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。', steps: ['筛选店铺', '打开配置抽屉', '保存或导出'] },
|
||||
'product-categories': { text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。', steps: ['搜索类目', '展开层级', '新增或编辑'] },
|
||||
'image-video-tasks': { text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。', steps: ['设置筛选', '查看任务结果', '按需处理任务'] },
|
||||
'shop-data-crawl-tasks': { text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。', steps: ['筛选任务', '检查状态', '执行批量操作'] },
|
||||
history: { text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。', steps: ['设置时间范围', '筛选记录', '查看结果预览'] },
|
||||
version: { text: '上传版本后请核对版本号和下载链接,再通知用户更新。', steps: ['上传压缩包', '检查版本记录', '维护历史版本'] },
|
||||
'digital-human-version': { text: '数字人版本需先上传草稿,再发布并标记最新版本。', steps: ['上传草稿', '确认更新日志', '发布或设为最新'] }
|
||||
};
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
var value = cleanText(message);
|
||||
if (!value || !toastRegion) return;
|
||||
var item = document.createElement('div');
|
||||
item.className = 'admin-toast' + (type === 'error' ? ' is-error' : type === 'info' ? ' is-info' : '');
|
||||
var content = document.createElement('span');
|
||||
content.className = 'admin-toast__text';
|
||||
content.textContent = value;
|
||||
item.appendChild(content);
|
||||
toastRegion.appendChild(item);
|
||||
window.setTimeout(function () {
|
||||
item.style.opacity = '0';
|
||||
item.style.transform = 'translateY(-6px)';
|
||||
item.style.transition = 'opacity 160ms ease, transform 160ms ease';
|
||||
window.setTimeout(function () { item.remove(); }, 180);
|
||||
}, type === 'error' ? 5200 : 3200);
|
||||
}
|
||||
|
||||
window.__adminToast = showToast;
|
||||
|
||||
function activeTabName() {
|
||||
var tab = document.querySelector('#adminMenu .tab.active');
|
||||
return tab ? (tab.dataset.tab || '') : '';
|
||||
}
|
||||
|
||||
function updateGuide(tabName) {
|
||||
if (!guide || !guideText || !guideSteps) return;
|
||||
var config = guides[tabName] || { text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。', steps: ['选择筛选条件', '处理记录', '核对反馈'] };
|
||||
guideText.textContent = config.text;
|
||||
guideSteps.innerHTML = (config.steps || []).map(function (step) { return '<li>' + step + '</li>'; }).join('');
|
||||
guide.dataset.tab = tabName || '';
|
||||
}
|
||||
|
||||
window.__adminUpdateOperationGuide = updateGuide;
|
||||
|
||||
function setGuideCollapsed(collapsed) {
|
||||
if (!guide || !guideToggle) return;
|
||||
guide.classList.toggle('is-collapsed', collapsed);
|
||||
guideToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
guideToggle.textContent = collapsed ? '展开提示' : '收起提示';
|
||||
try { localStorage.setItem('shufuAdminGuideCollapsed', collapsed ? '1' : '0'); } catch (error) {}
|
||||
}
|
||||
|
||||
if (guideToggle) {
|
||||
var collapsed = false;
|
||||
try { collapsed = localStorage.getItem('shufuAdminGuideCollapsed') === '1'; } catch (error) {}
|
||||
setGuideCollapsed(collapsed);
|
||||
guideToggle.addEventListener('click', function () {
|
||||
setGuideCollapsed(!guide.classList.contains('is-collapsed'));
|
||||
});
|
||||
}
|
||||
|
||||
function hashTabName() {
|
||||
var raw = (window.location.hash || '').replace(/^#/, '');
|
||||
var match = raw.match(/(?:^|&)tab=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function syncTabHash(tabName) {
|
||||
if (!tabName || !window.history || !window.history.replaceState) return;
|
||||
var next = '#tab=' + encodeURIComponent(tabName);
|
||||
if (window.location.hash !== next) window.history.replaceState(null, '', next);
|
||||
}
|
||||
|
||||
function navigateToHash(attemptsLeft) {
|
||||
var tabName = hashTabName();
|
||||
if (!tabName) {
|
||||
updateGuide(activeTabName());
|
||||
return;
|
||||
}
|
||||
var tab = document.querySelector('#adminMenu .tab[data-tab="' + tabName + '"]');
|
||||
if (tab && typeof tab.onclick === 'function') {
|
||||
if (!tab.classList.contains('active')) tab.click();
|
||||
else updateGuide(tabName);
|
||||
return;
|
||||
}
|
||||
if (attemptsLeft > 0) {
|
||||
window.setTimeout(function () { navigateToHash(attemptsLeft - 1); }, 80);
|
||||
} else {
|
||||
updateGuide(activeTabName());
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
if (!confirmMask) return;
|
||||
confirmMask.classList.remove('show');
|
||||
confirmMask.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('admin-confirm-open');
|
||||
var focus = previousFocus;
|
||||
pendingConfirmButton = null;
|
||||
previousFocus = null;
|
||||
if (focus && focus.isConnected) focus.focus();
|
||||
}
|
||||
|
||||
function openConfirm(button) {
|
||||
if (!confirmMask || !confirmMessage || !confirmAccept) return;
|
||||
pendingConfirmButton = button;
|
||||
previousFocus = document.activeElement;
|
||||
var customMessage = cleanText(button.dataset.confirmMessage);
|
||||
var label = cleanText(button.getAttribute('aria-label') || button.textContent || '删除');
|
||||
var subject = cleanText(button.dataset.name || button.dataset.value || button.dataset.shopManageName || button.dataset.shopName || button.dataset.ziniaoAccountName || '');
|
||||
var country = cleanText(button.dataset.country || '');
|
||||
if (!customMessage && country && subject) subject += '(' + country + ')';
|
||||
if (!customMessage && subject) customMessage = (button.classList.contains('btn-danger') ? '确认删除“' : '确认执行“') + subject + '”吗?此操作可能影响已有数据。';
|
||||
if (confirmTitle) confirmTitle.textContent = button.classList.contains('btn-danger') ? '删除前确认' : '请确认操作';
|
||||
confirmMessage.textContent = customMessage || ('确认执行“' + label + '”吗?此操作可能影响已有数据。');
|
||||
confirmAccept.textContent = button.dataset.confirmActionLabel || (button.classList.contains('btn-danger') ? '确认删除' : '确认操作');
|
||||
confirmMask.classList.add('show');
|
||||
confirmMask.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('admin-confirm-open');
|
||||
window.setTimeout(function () { confirmAccept.focus(); }, 0);
|
||||
}
|
||||
|
||||
function keepConfirmFocus(event) {
|
||||
if (!confirmMask || !confirmMask.classList.contains('show') || event.key !== 'Tab') return;
|
||||
var focusable = Array.prototype.filter.call(confirmMask.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), function (el) {
|
||||
return !el.disabled && el.offsetParent !== null;
|
||||
});
|
||||
if (!focusable.length) return;
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
if (confirmCancel) confirmCancel.addEventListener('click', closeConfirm);
|
||||
if (confirmMask) confirmMask.addEventListener('click', function (event) {
|
||||
if (event.target === confirmMask) closeConfirm();
|
||||
});
|
||||
if (confirmAccept) confirmAccept.addEventListener('click', function () {
|
||||
var target = pendingConfirmButton;
|
||||
closeConfirm();
|
||||
if (!target) return;
|
||||
window.__adminConfirmBypass = true;
|
||||
try { target.click(); }
|
||||
finally { window.setTimeout(function () { window.__adminConfirmBypass = false; }, 0); }
|
||||
});
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' && event.target && event.target.matches && event.target.matches('input:not([type="file"]), select') && !event.target.closest('textarea')) {
|
||||
var searchScope = event.target.closest('.form-row, .form-box');
|
||||
var searchButton = searchScope && searchScope.querySelector('button[id^="btnSearch"], button[id*="Search"]');
|
||||
if (searchButton && !searchButton.disabled) {
|
||||
event.preventDefault();
|
||||
searchButton.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape' && confirmMask && confirmMask.classList.contains('show')) {
|
||||
event.preventDefault();
|
||||
closeConfirm();
|
||||
return;
|
||||
}
|
||||
keepConfirmFocus(event);
|
||||
});
|
||||
|
||||
var nativeAlert = window.alert ? window.alert.bind(window) : null;
|
||||
window.alert = function (message) {
|
||||
showToast(message, /失败|错误|无权|不能为空|不正确|异常/.test(String(message || '')) ? 'error' : 'info');
|
||||
};
|
||||
var nativeConfirm = window.confirm ? window.confirm.bind(window) : null;
|
||||
window.confirm = function (message) {
|
||||
if (window.__adminConfirmBypass) return true;
|
||||
return nativeConfirm ? nativeConfirm(message) : false;
|
||||
};
|
||||
|
||||
function updatePageBusy() {
|
||||
if (!mainContent) return;
|
||||
mainContent.setAttribute('aria-busy', activeFetches || activeXhrs ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function startBusy() {
|
||||
var button = window.__adminLastActionButton;
|
||||
if (!button || !button.isConnected || button.disabled || button.classList.contains('tab') || button.classList.contains('menu-group-title') || button === confirmAccept) return;
|
||||
busyButton = button;
|
||||
busyWasDisabled = button.disabled;
|
||||
button.classList.add('is-busy');
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function finishBusy() {
|
||||
var finishedButton = busyButton;
|
||||
if (finishedButton && finishedButton.isConnected) {
|
||||
finishedButton.classList.remove('is-busy');
|
||||
finishedButton.removeAttribute('aria-busy');
|
||||
if (!busyWasDisabled) finishedButton.disabled = false;
|
||||
}
|
||||
if (window.__adminLastActionButton === finishedButton) window.__adminLastActionButton = null;
|
||||
busyButton = null;
|
||||
busyWasDisabled = false;
|
||||
}
|
||||
|
||||
if (window.fetch) {
|
||||
var nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = function () {
|
||||
activeFetches += 1;
|
||||
updatePageBusy();
|
||||
if (activeFetches === 1) startBusy();
|
||||
var request;
|
||||
try { request = nativeFetch.apply(window, arguments); }
|
||||
catch (error) { activeFetches = Math.max(0, activeFetches - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
|
||||
return Promise.resolve(request).finally(function () {
|
||||
activeFetches = Math.max(0, activeFetches - 1);
|
||||
updatePageBusy();
|
||||
if (!activeFetches && !activeXhrs) finishBusy();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (window.XMLHttpRequest) {
|
||||
var nativeSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
activeXhrs += 1;
|
||||
updatePageBusy();
|
||||
if (activeXhrs === 1) startBusy();
|
||||
this.addEventListener('loadend', function () {
|
||||
activeXhrs = Math.max(0, activeXhrs - 1);
|
||||
updatePageBusy();
|
||||
if (!activeFetches && !activeXhrs) finishBusy();
|
||||
}, { once: true });
|
||||
try { return nativeSend.apply(this, arguments); }
|
||||
catch (error) { activeXhrs = Math.max(0, activeXhrs - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
|
||||
};
|
||||
}
|
||||
|
||||
function addButtonHint(button) {
|
||||
if (!button || button.title) return;
|
||||
var label = cleanText(button.textContent);
|
||||
if (label === '编辑') button.title = '编辑当前记录';
|
||||
else if (label === '删除') button.title = '删除当前记录,需二次确认';
|
||||
else if (label === '查询') button.title = '按当前筛选条件查询';
|
||||
else if (/^导出/.test(label)) button.title = '导出当前筛选结果';
|
||||
else if (/^上传并/.test(label)) button.title = '上传文件并执行相应操作';
|
||||
else if (label === '管理分组') button.title = '新增、编辑或删除分组';
|
||||
else if (label === '选择店铺') button.title = '从店铺列表选择并回填';
|
||||
}
|
||||
|
||||
function enhance(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
scope.querySelectorAll('button').forEach(addButtonHint);
|
||||
scope.querySelectorAll('.table-ellipsis').forEach(function (element) {
|
||||
if (!element.title) element.title = cleanText(element.textContent);
|
||||
});
|
||||
scope.querySelectorAll('.empty-tip').forEach(function (element) { element.setAttribute('role', 'status'); });
|
||||
scope.querySelectorAll('.msg').forEach(function (element) {
|
||||
var value = cleanText(element.textContent);
|
||||
if (!value || (!element.classList.contains('ok') && !element.classList.contains('err'))) return;
|
||||
var key = value + '|' + element.className;
|
||||
if (element.dataset.adminToastKey === key) return;
|
||||
element.dataset.adminToastKey = key;
|
||||
showToast(value, element.classList.contains('err') ? 'error' : 'success');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('button') : null;
|
||||
if (!button || button.disabled) return;
|
||||
if (button.classList.contains('tab') || button.classList.contains('menu-group-title')) {
|
||||
window.__adminLastActionButton = null;
|
||||
} else if (button !== confirmAccept) {
|
||||
window.__adminLastActionButton = button;
|
||||
}
|
||||
if (button.classList.contains('tab')) {
|
||||
window.setTimeout(function () {
|
||||
var tabName = activeTabName();
|
||||
updateGuide(tabName);
|
||||
syncTabHash(tabName);
|
||||
}, 0);
|
||||
}
|
||||
if ((!button.matches('.btn-danger') && !button.hasAttribute('data-admin-confirm')) || button === confirmAccept || window.__adminConfirmBypass) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
openConfirm(button);
|
||||
}, true);
|
||||
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
enhance(mutation.target && mutation.target.nodeType === 1 ? mutation.target : document);
|
||||
if (mutation.type === 'attributes' && mutation.target.matches && mutation.target.matches('#adminMenu .tab')) {
|
||||
window.setTimeout(function () { updateGuide(activeTabName()); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
enhance(document);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
window.addEventListener('hashchange', function () { navigateToHash(0); });
|
||||
navigateToHash(25);
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,125 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* /api/admin 兜底鉴权过滤器单测:非 admin 路径放行、OPTIONS/豁免前缀放行、
|
||||
* 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。
|
||||
*/
|
||||
class AdminApiGuardFilterTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private AdminApiGuardFilter newFilter(AdminAuthSupport adminAuthSupport, boolean enabled, String exemptPrefixes) {
|
||||
AdminApiGuardFilter filter = new AdminApiGuardFilter(adminAuthSupport, objectMapper);
|
||||
ReflectionTestUtils.setField(filter, "enabled", enabled);
|
||||
ReflectionTestUtils.setField(filter, "exemptPrefixes", exemptPrefixes);
|
||||
return filter;
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminApiPathIsNotGuarded() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin.html");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(authSupport, never()).requireUserOrInternal(any());
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validIdentityPassesThrough() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
when(authSupport.requireUserOrInternal(any())).thenReturn(new AdminUserEntity());
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/users");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
assertThat(response.getContentAsString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousAdminApiRequestRejectedWith401Body() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
when(authSupport.requireUserOrInternal(any()))
|
||||
.thenThrow(new BusinessException(401, "未登录"));
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/shop-keys");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
JsonNode body = objectMapper.readTree(response.getContentAsString());
|
||||
assertThat(body.path("success").asBoolean()).isFalse();
|
||||
assertThat(body.path("code").asInt()).isEqualTo(401);
|
||||
assertThat(chain.getRequest()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionsPreflightIsExempt() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/api/admin/shop-keys");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(authSupport, never()).requireUserOrInternal(any());
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exemptPrefixPassesThrough() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "/api/admin/shop-credential-checks");
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/api/admin/shop-credential-checks/poll");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(authSupport, never()).requireUserOrInternal(any());
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledGuardPassesEverything() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, false, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/shop-keys");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(authSupport, never()).requireUserOrInternal(any());
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user