feat(admin): A1 管理后台收敛 Java 单后台完整实现并修复 guard 误拦内部令牌
Build Backend JAR / build (push) Has been cancelled
Build Backend JAR / build (push) Has been cancelled
- Java 补齐后台全部迁移差集:shopduplicatecheck 店铺数据重复检查模块(V108 扫描表+查询/扫描服务)、 PinyinAbbrUtil 拼音缩写、ImageHistory 接口调整为内部可用、AdminUser 支持内部令牌操作并放宽列表上限 - Flask 后台 admin_api.py 路由收敛转发 Java、admin.html/admin.js 适配新后台形态 - AdminApiGuardFilter 对可信 X-Internal-Token 放行(controller 自校验兜底),修复客户端仅凭 内部令牌调用 /api/admin/shop-manages/credential 被误拦 401 - 测试:AdminApiGuardFilterTest 补可信/假令牌用例;AdminUserServiceTest 补菜单权限 mock; shopduplicatecheck 新增查询/聚合/CSV 单测
This commit is contained in:
@@ -63,6 +63,12 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
// 可信内部令牌(X-Internal-Token 匹配服务端配置)直接放行:让"仅凭令牌即鉴权"的
|
||||
// 内部端点(如 /api/admin/shop-manages/credential)由 controller 自校验令牌兜底,
|
||||
// 避免 guard 把它们误拦为 401。放行后仍可达 controller,不会裸露敏感端点。
|
||||
if (adminAuthSupport.isTrustedInternalToken(request)) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : exemptSet()) {
|
||||
if (uri.startsWith(prefix)) {
|
||||
return true;
|
||||
|
||||
+4
-4
@@ -39,7 +39,7 @@ public class AdminUserController {
|
||||
@RequestParam(name = "search", required = false) String search,
|
||||
@RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
|
||||
String kw = (username == null || username.isBlank()) ? search : username;
|
||||
Long filterCreatedBy = createdById != null ? createdById : adminId;
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
|
||||
@@ -50,7 +50,7 @@ public class AdminUserController {
|
||||
@Operation(summary = "创建用户")
|
||||
public ApiResponse<Long> createUser(HttpServletRequest request,
|
||||
@RequestBody AdminUserCreateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
|
||||
Long userId = adminUserService.createUser(currentUser, body);
|
||||
return ApiResponse.success("用户创建成功", userId);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public class AdminUserController {
|
||||
public ApiResponse<Void> updateUser(HttpServletRequest request,
|
||||
@PathVariable Long uid,
|
||||
@RequestBody AdminUserUpdateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
|
||||
adminUserService.updateUser(currentUser, uid, body);
|
||||
return ApiResponse.success("更新成功", null);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class AdminUserController {
|
||||
@Operation(summary = "删除用户")
|
||||
public ApiResponse<Void> deleteUser(HttpServletRequest request,
|
||||
@PathVariable Long uid) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
|
||||
adminUserService.deleteUser(currentUser, uid);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
+2
@@ -16,4 +16,6 @@ public class AdminUserItemVo {
|
||||
private String creatorUsername;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
@JsonProperty("pinyin_abbr")
|
||||
private String pinyinAbbr;
|
||||
}
|
||||
|
||||
+4
@@ -14,5 +14,9 @@ public class AdminUserListVo {
|
||||
private Integer pageSize;
|
||||
@JsonProperty("current_user_role")
|
||||
private String currentUserRole;
|
||||
@JsonProperty("current_user_id")
|
||||
private Long currentUserId;
|
||||
@JsonProperty("current_user_username")
|
||||
private String currentUserUsername;
|
||||
private List<AdminBriefVo> admins;
|
||||
}
|
||||
|
||||
+18
-1
@@ -9,6 +9,7 @@ import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.admin.util.PinyinAbbrUtil;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
|
||||
@@ -46,10 +47,14 @@ public class AdminUserService {
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
// 用户列表被多个后台页面用作用户选择器,权限按 Flask 的宽菜单 OR 预检(任一后台菜单可见即可拉取)
|
||||
ensureAdminUsersMenuAccess(currentUser, List.of(
|
||||
"admin_users", "admin_history", "admin_shop_manage", "admin_skip_price_asin",
|
||||
"admin_query_asin", "admin_dedupe_total_data", "admin_invalid_asin_data"));
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 5) safeSize = 5;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
if (safeSize > 999) safeSize = 999;
|
||||
|
||||
String kw = username == null ? "" : username.trim();
|
||||
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
|
||||
@@ -97,6 +102,8 @@ public class AdminUserService {
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setCurrentUserRole(role);
|
||||
vo.setCurrentUserId(currentUser.getId());
|
||||
vo.setCurrentUserUsername(currentUser.getUsername() == null ? "" : currentUser.getUsername());
|
||||
vo.setAdmins(admins);
|
||||
return vo;
|
||||
}
|
||||
@@ -107,6 +114,7 @@ public class AdminUserService {
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
ensureAdminUsersMenuAccess(currentUser, List.of("admin_users"));
|
||||
String username = request.getUsername() == null ? "" : request.getUsername().trim();
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String wantRole = request.getRole() == null ? "normal" : request.getRole().trim();
|
||||
@@ -165,6 +173,7 @@ public class AdminUserService {
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
ensureAdminUsersMenuAccess(currentUser, List.of("admin_users"));
|
||||
String password = request.getPassword();
|
||||
String wantRole = request.getRole() == null ? null : request.getRole().trim();
|
||||
|
||||
@@ -233,6 +242,7 @@ public class AdminUserService {
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
ensureAdminUsersMenuAccess(currentUser, List.of("admin_users"));
|
||||
if (currentUser.getId().equals(uid)) {
|
||||
throw new BusinessException("不能删除当前登录账号");
|
||||
}
|
||||
@@ -284,6 +294,13 @@ public class AdminUserService {
|
||||
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void ensureAdminUsersMenuAccess(AdminUserEntity operator, List<String> columnKeys) {
|
||||
if (!permissionMenuService.hasAnyAdminMenu(operator, columnKeys)) {
|
||||
throw new BusinessException(403, "无权访问用户管理模块");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -12,6 +12,11 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -21,6 +26,12 @@ public class AdminAuthSupport {
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
@Value("${aiimage.security.internal-token:}")
|
||||
private String internalToken;
|
||||
|
||||
@Value("${aiimage.security.internal-token-file:}")
|
||||
private String internalTokenFile;
|
||||
|
||||
/** 解析当前请求的用户;token 缺失或无效抛 401。 */
|
||||
public AdminUserEntity requireUser(HttpServletRequest request) {
|
||||
String token = resolveToken(request);
|
||||
@@ -73,6 +84,118 @@ public class AdminAuthSupport {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
|
||||
public AdminUserEntity requireAdminOrInternal(HttpServletRequest request) {
|
||||
try {
|
||||
return requireAdmin(request);
|
||||
} catch (BusinessException authFailure) {
|
||||
AdminUserEntity internalOperator = resolveInternalOperator(request, true);
|
||||
if (internalOperator != null) {
|
||||
return internalOperator;
|
||||
}
|
||||
throw authFailure;
|
||||
}
|
||||
}
|
||||
|
||||
/** JWT 优先;无 JWT 时以可信内部代理身份回退(仅要求用户存在)。 */
|
||||
public AdminUserEntity requireUserOrInternal(HttpServletRequest request) {
|
||||
try {
|
||||
return requireUser(request);
|
||||
} catch (BusinessException authFailure) {
|
||||
AdminUserEntity internalOperator = resolveInternalOperator(request, false);
|
||||
if (internalOperator != null) {
|
||||
return internalOperator;
|
||||
}
|
||||
throw authFailure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flask 传统后台用 session 登录、无法签发 Java JWT,只能凭共享密钥
|
||||
* (显式配置或每个用户的本地 token 文件)标识已认证管理员。这里仍会
|
||||
* 回库校验管理员角色,superAdmin 查询标志不参与判定。
|
||||
*/
|
||||
private AdminUserEntity resolveInternalOperator(HttpServletRequest request, boolean requireAdmin) {
|
||||
if (!isTrustedInternalRequest(request)) {
|
||||
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 {
|
||||
Long operatorId = Long.parseLong(rawOperatorId.trim());
|
||||
AdminUserEntity operator = adminUserMapper.selectById(operatorId);
|
||||
if (operator == null) {
|
||||
return null;
|
||||
}
|
||||
if (requireAdmin && currentRole(operator) == null) {
|
||||
return null;
|
||||
}
|
||||
return operator;
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTrustedInternalRequest(HttpServletRequest request) {
|
||||
String suppliedToken = request.getHeader("X-Internal-Token");
|
||||
String expectedToken = resolveExpectedInternalToken();
|
||||
if (expectedToken.isBlank() || suppliedToken == null || suppliedToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return expectedToken.equals(suppliedToken.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅判断请求是否携带可信内部令牌(不要求 operatorId)。供 AdminApiGuardFilter
|
||||
* 在兜底鉴权前放行内部自动化调用(仅凭 X-Internal-Token 即鉴权的端点,如
|
||||
* /api/admin/shop-manages/credential 由 controller 自校验令牌),
|
||||
* 避免 guard 把合法的内部调用误拦为 401。
|
||||
*/
|
||||
public boolean isTrustedInternalToken(HttpServletRequest request) {
|
||||
return isTrustedInternalRequest(request);
|
||||
}
|
||||
|
||||
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 String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.nanri.aiimage.modules.admin.util;
|
||||
|
||||
/**
|
||||
* 用户名拼音首字母缩写(分组管理/通讯录用),无第三方依赖。
|
||||
* 与 Python backend/blueprints/admin_api.py 的 _username_pinyin_abbr 语义保持一致:
|
||||
* GB2312 区位码按拼音音序排列,用码位区间判定首字母;仅覆盖 GB2312 汉字。
|
||||
*/
|
||||
public final class PinyinAbbrUtil {
|
||||
|
||||
private static final char[] PYINYIN_LETTERS = {
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M',
|
||||
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z'
|
||||
};
|
||||
private static final int[] PYINYIN_BOUNDS = {
|
||||
0xB0A1, 0xB0C5, 0xB2C1, 0xB4EE, 0xB6EA, 0xB7A2, 0xB8C1, 0xB9FE,
|
||||
0xBBF7, 0xBFA6, 0xC0AC, 0xC2E8, 0xC4C3, 0xC5B6, 0xC5BE, 0xC6DA,
|
||||
0xC8BB, 0xC8F6, 0xCBFA, 0xCDDA, 0xCEF4, 0xD1B9, 0xD4D1
|
||||
};
|
||||
private static final int PYINYIN_BOUNDS_END = 0xF7FF;
|
||||
|
||||
private PinyinAbbrUtil() {
|
||||
}
|
||||
|
||||
/** 返回用户名各字符的拼音首字母串('张伟恒' -> 'ZWH'),空输入或非 GB2312 汉字返回 '#'。 */
|
||||
public static String abbr(String username) {
|
||||
if (username == null) {
|
||||
return "#";
|
||||
}
|
||||
String name = username.trim();
|
||||
if (name.isEmpty()) {
|
||||
return "#";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
sb.append(initial(name.charAt(i)));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static char initial(char c) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c;
|
||||
}
|
||||
if (c >= 'A' && c <= 'Z') {
|
||||
return c;
|
||||
}
|
||||
if (c >= 'a' && c <= 'z') {
|
||||
return (char) (c - 32);
|
||||
}
|
||||
if (c < 0x4E00 || c > 0x9FA5) {
|
||||
return '#';
|
||||
}
|
||||
try {
|
||||
byte[] gb = String.valueOf(c).getBytes("GB2312");
|
||||
if (gb.length != 2) {
|
||||
return '#';
|
||||
}
|
||||
int low = ((gb[0] & 0xFF) << 8) | (gb[1] & 0xFF);
|
||||
if (low < 0xB0A1 || low >= PYINYIN_BOUNDS_END) {
|
||||
return '#';
|
||||
}
|
||||
for (int i = PYINYIN_LETTERS.length - 1; i >= 0; i--) {
|
||||
if (low >= PYINYIN_BOUNDS[i]) {
|
||||
return PYINYIN_LETTERS[i];
|
||||
}
|
||||
}
|
||||
return '#';
|
||||
} catch (Exception e) {
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -1,9 +1,12 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
|
||||
import com.nanri.aiimage.modules.imagehistory.service.ImageHistoryService;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
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;
|
||||
@@ -13,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@@ -21,6 +26,7 @@ public class ImageHistoryController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ImageHistoryService imageHistoryService;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "分页查询所有用户生成历史")
|
||||
@@ -30,7 +36,10 @@ public class ImageHistoryController {
|
||||
@RequestParam(name = "user_id", required = false) Long userId,
|
||||
@RequestParam(name = "time_start", required = false) String timeStart,
|
||||
@RequestParam(name = "time_end", required = false) String timeEnd) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||
if (!permissionMenuService.hasAnyAdminMenu(operator, List.of("admin_history"))) {
|
||||
throw new BusinessException(403, "无权访问生成记录模块");
|
||||
}
|
||||
ImageHistoryListVo vo = imageHistoryService.listHistory(page, pageSize, userId, timeStart, timeEnd);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.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.shopduplicatecheck.service.ShopDataDuplicateCheckQueryService;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 店铺数据重复检查(撞款):overview / items / detail / export 四端点,契约与 Flask 页面一致。
|
||||
* JWT 管理员或 Flask 内部代理(X-Internal-Token + operatorId)均可调用;统一菜单+数据权限校验。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/shop-data-crawl")
|
||||
@Tag(name = "店铺数据重复检查")
|
||||
public class AdminShopDataDuplicateCheckController {
|
||||
|
||||
@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 ShopDataDuplicateCheckScanService scanService;
|
||||
private final ShopDataDuplicateCheckQueryService queryService;
|
||||
|
||||
@GetMapping("/duplicate-check-overview")
|
||||
@Operation(summary = "撞款指标与店铺分布(force=1 触发同步重扫)")
|
||||
public ApiResponse<Object> overview(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(name = "force", required = false) String force) {
|
||||
RequestOperator operator = requireDuplicateCheckAccess(request);
|
||||
boolean forced = force != null && ("1".equals(force.trim().toLowerCase())
|
||||
|| "true".equals(force.trim().toLowerCase()) || "yes".equals(force.trim().toLowerCase()));
|
||||
if (forced) {
|
||||
log.info("[shop-duplicate-check] force 手动重扫 operator_id={}", operator.id());
|
||||
scanService.scanNow();
|
||||
}
|
||||
DuplicateScanView view = scanService.loadLatest();
|
||||
if (view == null) {
|
||||
return ApiResponse.success(queryService.pendingOverview());
|
||||
}
|
||||
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
return ApiResponse.success(queryService.overviewData(view, visibleKeys));
|
||||
}
|
||||
|
||||
@GetMapping("/duplicate-check-items")
|
||||
@Operation(summary = "撞款矩阵/明细(分页)")
|
||||
public ApiResponse<Object> items(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(name = "page", required = false) String page,
|
||||
@RequestParam(name = "page_size", required = false) String pageSize,
|
||||
@RequestParam(name = "view", required = false) String view,
|
||||
@RequestParam(name = "asin", required = false) String asin,
|
||||
@RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@RequestParam(name = "country", required = false) String country,
|
||||
@RequestParam(name = "site", required = false) String site,
|
||||
@RequestParam(name = "date_from", required = false) String dateFrom,
|
||||
@RequestParam(name = "date_to", required = false) String dateTo) {
|
||||
RequestOperator operator = requireDuplicateCheckAccess(request);
|
||||
int safePage = parsePage(page);
|
||||
int safeSize = clamp(parseSize(pageSize, 20), 10, 100);
|
||||
String viewMode = view == null ? "monitor" : view.trim().toLowerCase();
|
||||
ShopDataDuplicateCheckQueryService.Filters filters =
|
||||
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
|
||||
DuplicateScanView scan = scanService.loadLatest();
|
||||
if (scan == null) {
|
||||
return ApiResponse.success(queryService.pendingList(safePage, safeSize));
|
||||
}
|
||||
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
return ApiResponse.success(queryService.itemsData(scan, visibleKeys, safePage, safeSize, viewMode, filters));
|
||||
}
|
||||
|
||||
@GetMapping("/duplicate-check-detail")
|
||||
@Operation(summary = "撞款详情卡片(分页,恒跨店重复)")
|
||||
public ApiResponse<Object> detail(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(name = "page", required = false) String page,
|
||||
@RequestParam(name = "page_size", required = false) String pageSize,
|
||||
@RequestParam(name = "asin", required = false) String asin,
|
||||
@RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@RequestParam(name = "country", required = false) String country,
|
||||
@RequestParam(name = "site", required = false) String site,
|
||||
@RequestParam(name = "date_from", required = false) String dateFrom,
|
||||
@RequestParam(name = "date_to", required = false) String dateTo) {
|
||||
RequestOperator operator = requireDuplicateCheckAccess(request);
|
||||
int safePage = parsePage(page);
|
||||
int safeSize = clamp(parseSize(pageSize, 6), 1, 24);
|
||||
ShopDataDuplicateCheckQueryService.Filters filters =
|
||||
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
|
||||
DuplicateScanView scan = scanService.loadLatest();
|
||||
if (scan == null) {
|
||||
return ApiResponse.success(queryService.pendingDetail(safePage, safeSize));
|
||||
}
|
||||
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
return ApiResponse.success(queryService.detailData(scan, visibleKeys, safePage, safeSize, filters));
|
||||
}
|
||||
|
||||
@GetMapping("/duplicate-check-export")
|
||||
@Operation(summary = "撞款导出 CSV(UTF-8 BOM,逐行=一条上架记录)")
|
||||
public void export(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
@RequestParam(name = "view", required = false) String view,
|
||||
@RequestParam(name = "asin", required = false) String asin,
|
||||
@RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@RequestParam(name = "country", required = false) String country,
|
||||
@RequestParam(name = "site", required = false) String site,
|
||||
@RequestParam(name = "date_from", required = false) String dateFrom,
|
||||
@RequestParam(name = "date_to", required = false) String dateTo) {
|
||||
RequestOperator operator = requireDuplicateCheckAccess(request);
|
||||
DuplicateScanView scan = scanService.loadLatest();
|
||||
if (scan == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "暂无扫描结果,请先点击「重新分析」");
|
||||
}
|
||||
String viewMode = view == null ? "monitor" : view.trim().toLowerCase();
|
||||
ShopDataDuplicateCheckQueryService.Filters filters =
|
||||
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
|
||||
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
|
||||
try {
|
||||
response.setContentType("text/csv; charset=utf-8");
|
||||
DownloadHeaderUtil.setAttachment(response, "shop-data-duplicate-check.csv");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
queryService.writeExport(scan, visibleKeys, viewMode, filters, response.getOutputStream());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-duplicate-check] 导出 CSV 失败: {}", ex.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "导出失败");
|
||||
}
|
||||
}
|
||||
|
||||
private record RequestOperator(Long id, boolean superAdmin) {
|
||||
}
|
||||
|
||||
private RequestOperator requireDuplicateCheckAccess(HttpServletRequest request) {
|
||||
AdminUserEntity operator;
|
||||
try {
|
||||
operator = adminAuthSupport.requireAdmin(request);
|
||||
} catch (BusinessException authFailure) {
|
||||
operator = resolveInternalOperator(request);
|
||||
if (operator == null) {
|
||||
throw authFailure;
|
||||
}
|
||||
}
|
||||
permissionMenuService.requireShopDataCrawlTaskAccess(operator);
|
||||
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
return new RequestOperator(operator.getId(), superAdmin);
|
||||
}
|
||||
|
||||
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 parsePage(String page) {
|
||||
if (page == null || page.isBlank()) {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return Math.max(1, Integer.parseInt(page.trim()));
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new BusinessException(400, "page 参数不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseSize(String raw, int defaultValue) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Math.max(1, Integer.parseInt(raw.trim()));
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new BusinessException(400, "page_size 参数不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanLightRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanFullRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ShopDataDuplicateScanMapper extends BaseMapper<ShopDataDuplicateScanEntity> {
|
||||
|
||||
/** 轻量判活:仅取最新 SUCCESS 行 id/created_at,避免每次请求读大 payload。 */
|
||||
@Select("SELECT id, created_at AS createdAt FROM shop_data_duplicate_scan "
|
||||
+ "WHERE status = 'SUCCESS' ORDER BY id DESC LIMIT 1")
|
||||
ScanLightRowDto selectLatestLightRow();
|
||||
|
||||
/** 全量读取最新 SUCCESS 行(含 summary/payload 反序列化)。 */
|
||||
@Select("SELECT id, summary_json AS summaryJson, payload_json AS payloadJson, "
|
||||
+ "created_at AS createdAt FROM shop_data_duplicate_scan "
|
||||
+ "WHERE status = 'SUCCESS' ORDER BY id DESC LIMIT 1")
|
||||
ScanFullRowDto selectLatestFullRow();
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.mapper;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopGroupLabelDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopSourceRowDto;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 撞款扫描的数据源查询:等价 Python 后台 admin_api.py 中
|
||||
* _shops_with_latest_results(窗口函数取每店最新结果行)、
|
||||
* _shop_data_crawl_group_names(店铺分组名)与 _shop_data_managed_shop_names(角色可管店铺)。
|
||||
*/
|
||||
@Mapper
|
||||
public interface ShopDuplicateCheckSourceMapper {
|
||||
|
||||
/**
|
||||
* 每家店铺最新一条 biz_file_result 行(shop 最新累计结果文件,含该店全部国家/记录)。
|
||||
* 结果文件 URL 非空即参与,不要求 task/result 成功(与 Python 语义一致)。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT ranked.result_id AS resultId,
|
||||
ranked.shop_name AS shopName,
|
||||
ranked.result_file_url AS resultFileUrl,
|
||||
ranked.result_file_size AS resultFileSize,
|
||||
ranked.request_json AS requestJson,
|
||||
ranked.country_codes_json AS countryCodesJson
|
||||
FROM (
|
||||
SELECT r.id AS result_id,
|
||||
r.source_filename AS shop_name,
|
||||
r.result_file_url,
|
||||
r.result_file_size,
|
||||
t.request_json,
|
||||
df.country_codes_json,
|
||||
COALESCE(df.last_success_at, df.updated_at, t.finished_at,
|
||||
t.updated_at, t.created_at) AS latest_file_updated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TRIM(COALESCE(r.source_filename, ''))
|
||||
ORDER BY COALESCE(df.last_success_at, df.updated_at,
|
||||
t.finished_at, t.updated_at, t.created_at) DESC,
|
||||
r.id DESC) AS row_no
|
||||
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
|
||||
WHERE r.module_type = 'SHOP_DATA_CRAWL'
|
||||
AND t.module_type = 'SHOP_DATA_CRAWL'
|
||||
AND TRIM(COALESCE(r.result_file_url, '')) <> ''
|
||||
) ranked
|
||||
WHERE ranked.row_no = 1
|
||||
ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC
|
||||
""")
|
||||
List<ShopSourceRowDto> selectLatestResultRows();
|
||||
|
||||
/** 店铺 → 分组名(g.group_name 优先,多分组用「、」连接;店铺名 trim 精确匹配)。 */
|
||||
@Select("""
|
||||
<script>
|
||||
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
|
||||
<foreach collection='shopNames' item='name' open='(' separator=',' close=')'>#{name}</foreach>
|
||||
GROUP BY TRIM(sm.shop_name)
|
||||
</script>
|
||||
""")
|
||||
List<ShopGroupLabelDto> selectGroupLabels(@Param("shopNames") List<String> shopNames);
|
||||
|
||||
/**
|
||||
* 主管(admin 角色)可管店铺:自己创建或指派给自己的分组(created_by_id=me OR user_id=me)
|
||||
* 下的店铺名,trim 后去重。无分组时返回空集合(什么都看不到),与 Python 语义一致。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT DISTINCT TRIM(sm.shop_name) AS shopName
|
||||
FROM biz_shop_manage sm
|
||||
WHERE sm.group_id IN (
|
||||
SELECT id FROM biz_shop_manage_group
|
||||
WHERE created_by_id = #{operatorId} OR user_id = #{operatorId}
|
||||
)
|
||||
""")
|
||||
List<String> selectManagedShopNames(@Param("operatorId") Long operatorId);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 最新 SUCCESS 扫描行的完整字段。 */
|
||||
@Data
|
||||
public class ScanFullRowDto {
|
||||
|
||||
private Long id;
|
||||
private String summaryJson;
|
||||
private String payloadJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 最新 SUCCESS 扫描行的轻量字段(内存缓存判活用)。 */
|
||||
@Data
|
||||
public class ScanLightRowDto {
|
||||
|
||||
private Long id;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 店铺→分组名(多分组以「、」连接),g.group_name 优先于 sm.group_name。 */
|
||||
@Data
|
||||
public class ShopGroupLabelDto {
|
||||
|
||||
private String shopName;
|
||||
private String groupName;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 每店最新一条 biz_file_result 行(窗口查询产出,供扫描解析用)。 */
|
||||
@Data
|
||||
public class ShopSourceRowDto {
|
||||
|
||||
private Long resultId;
|
||||
private String shopName;
|
||||
private String resultFileUrl;
|
||||
private Long resultFileSize;
|
||||
private String requestJson;
|
||||
private String countryCodesJson;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.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;
|
||||
|
||||
/** 撞款扫描结果行(Python 端建表,Java 读写同表)。 */
|
||||
@Data
|
||||
@TableName("shop_data_duplicate_scan")
|
||||
public class ShopDataDuplicateScanEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String status;
|
||||
private String errorText;
|
||||
private String summaryJson;
|
||||
private String payloadJson;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** ASIN 维度明细:{asin, shop_count, record_count, occurrences[]}。 */
|
||||
public record DuplicateItem(
|
||||
@JsonProperty("asin") String asin,
|
||||
@JsonProperty("shop_count") int shopCount,
|
||||
@JsonProperty("record_count") int recordCount,
|
||||
@JsonProperty("occurrences") List<DuplicateOccurrence> occurrences) {
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单条上架记录(occurrence)。键名/键集与 Python 后台落库 payload 完全一致:
|
||||
* {asin, date, price, brand, shop_name, group_name, country_codes, country}。
|
||||
*/
|
||||
public record DuplicateOccurrence(
|
||||
@JsonProperty("asin") String asin,
|
||||
@JsonProperty("date") String date,
|
||||
@JsonProperty("price") String price,
|
||||
@JsonProperty("brand") String brand,
|
||||
@JsonProperty("shop_name") String shopName,
|
||||
@JsonProperty("group_name") String groupName,
|
||||
@JsonProperty("country_codes") List<String> countryCodes,
|
||||
@JsonProperty("country") String country) {
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 落库 payload:{shops:[...], items:[...]},与 Python 现行版本逐字一致。 */
|
||||
public record DuplicateScanPayload(
|
||||
@JsonProperty("shops") List<DuplicateShop> shops,
|
||||
@JsonProperty("items") List<DuplicateItem> items) {
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 全量扫描指标:{shop_count, asin_total, record_total, duplicate_asin_total,
|
||||
* duplicate_shop_count, site_count, asin_per_shop, source}。
|
||||
*/
|
||||
public record DuplicateScanSummary(
|
||||
@JsonProperty("shop_count") int shopCount,
|
||||
@JsonProperty("asin_total") int asinTotal,
|
||||
@JsonProperty("record_total") int recordTotal,
|
||||
@JsonProperty("duplicate_asin_total") int duplicateAsinTotal,
|
||||
@JsonProperty("duplicate_shop_count") int duplicateShopCount,
|
||||
@JsonProperty("site_count") int siteCount,
|
||||
@JsonProperty("asin_per_shop") double asinPerShop,
|
||||
@JsonProperty("source") String source) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.model.payload;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 单店分布:{shop_name, group_name, country_codes, asin_count, record_count}。 */
|
||||
public record DuplicateShop(
|
||||
@JsonProperty("shop_name") String shopName,
|
||||
@JsonProperty("group_name") String groupName,
|
||||
@JsonProperty("country_codes") List<String> countryCodes,
|
||||
@JsonProperty("asin_count") int asinCount,
|
||||
@JsonProperty("record_count") int recordCount) {
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckCsvWriter;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckTimeNormalizer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 撞款读侧查询:角色裁剪 + 筛选 + 分页 + detail 排序 + overview 指标重算 + CSV 展平。
|
||||
* 内存模型/排序语义与 Python 后台逐字对齐;返回结构为可直接序列化的 Map(键 snake_case,
|
||||
* 空态与 Python 响应完全一致:pending 时 summary 为 {} 空对象)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ShopDataDuplicateCheckQueryService {
|
||||
|
||||
static final DateTimeFormatter SORT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private final ShopDuplicateCheckSourceMapper sourceMapper;
|
||||
|
||||
/** 筛选条件(参数已按 Python 清洗规则处理:asin 大写、country/site 大写、日期取前 10)。 */
|
||||
public record Filters(String asin, String shopName, String country, String site,
|
||||
String dateFrom, String dateTo) {
|
||||
|
||||
public static Filters clean(String asin, String shopName, String country, String site,
|
||||
String dateFrom, String dateTo) {
|
||||
String normAsin = trim(asin).toUpperCase(Locale.ROOT);
|
||||
String normShop = trim(shopName);
|
||||
String normCountry = trim(country).toUpperCase(Locale.ROOT);
|
||||
String normSite = trim(site).toUpperCase(Locale.ROOT);
|
||||
String from = trim(dateFrom);
|
||||
String to = trim(dateTo);
|
||||
from = from.length() > 10 ? from.substring(0, 10) : from;
|
||||
to = to.length() > 10 ? to.substring(0, 10) : to;
|
||||
return new Filters(normAsin, normShop, normCountry, normSite, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String shopKey(String shopName) {
|
||||
return (shopName == null ? "" : shopName.trim()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** 主管可见店铺 key 集;超管用 null 表示全量。 */
|
||||
public Set<String> resolveVisibleShopKeys(Long operatorId, boolean superAdmin) {
|
||||
if (superAdmin) {
|
||||
return null;
|
||||
}
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (String name : sourceMapper.selectManagedShopNames(operatorId)) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
keys.add(shopKey(name));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 超管/null → 全量;否则保留可见店铺(shops 保持缓存序)。 */
|
||||
public List<DuplicateShop> cropShops(List<DuplicateShop> shops, Set<String> visibleKeys) {
|
||||
if (visibleKeys == null || shops == null) {
|
||||
return shops == null ? List.of() : shops;
|
||||
}
|
||||
List<DuplicateShop> result = new ArrayList<>();
|
||||
for (DuplicateShop shop : shops) {
|
||||
if (visibleKeys.contains(shopKey(shop.shopName()))) {
|
||||
result.add(shop);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 逐 ASIN 裁剪 occurrences 并重算 shop_count/record_count;items 保持缓存序。 */
|
||||
public List<DuplicateItem> cropItems(List<DuplicateItem> items, Set<String> visibleKeys) {
|
||||
if (visibleKeys == null || items == null) {
|
||||
return items == null ? List.of() : items;
|
||||
}
|
||||
List<DuplicateItem> result = new ArrayList<>(items.size());
|
||||
for (DuplicateItem item : items) {
|
||||
List<DuplicateOccurrence> kept = new ArrayList<>();
|
||||
Set<String> shops = new LinkedHashSet<>();
|
||||
for (DuplicateOccurrence occurrence : item.occurrences()) {
|
||||
if (visibleKeys.contains(shopKey(occurrence.shopName()))) {
|
||||
kept.add(occurrence);
|
||||
shops.add(occurrence.shopName());
|
||||
}
|
||||
}
|
||||
// 完全不可见的 ASIN 整体剔除(与 Python 主管数据范围语义一致)
|
||||
if (!kept.isEmpty()) {
|
||||
result.add(new DuplicateItem(item.asin(), shops.size(), kept.size(), kept));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> overviewData(DuplicateScanView view, Set<String> visibleKeys) {
|
||||
List<DuplicateShop> shops = cropShops(view.payload().shops(), visibleKeys);
|
||||
List<DuplicateItem> items = cropItems(view.payload().items(), visibleKeys);
|
||||
String source = view.summary() == null ? "" : safe(view.summary().source());
|
||||
DuplicateScanSummary recomputed = recomputeSummary(items, shops, source);
|
||||
return mapOf("pending", false, "scanned_at", safe(view.scannedAt()), "summary", recomputed, "shops", shops);
|
||||
}
|
||||
|
||||
/** overview 无扫描结果时的空态结构(pending=true,summary 为空对象)。 */
|
||||
public Map<String, Object> pendingOverview() {
|
||||
return mapOf("pending", true, "scanned_at", "", "summary", Collections.emptyMap(), "shops", Collections.emptyList());
|
||||
}
|
||||
|
||||
public Map<String, Object> pendingList(int page, int pageSize) {
|
||||
return mapOf("pending", true, "items", Collections.emptyList(), "shops", Collections.emptyList(),
|
||||
"total", 0, "page", page, "page_size", pageSize, "scanned_at", "");
|
||||
}
|
||||
|
||||
public Map<String, Object> itemsData(DuplicateScanView view, Set<String> visibleKeys,
|
||||
int page, int pageSize, String viewMode, Filters filters) {
|
||||
boolean monitor = "monitor".equals(viewMode);
|
||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
|
||||
int total = matched.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<DuplicateItem> slice = matched.isEmpty() ? List.of()
|
||||
: new ArrayList<>(matched.subList(from, to));
|
||||
List<DuplicateShop> shops = cropShops(view.payload().shops(), visibleKeys);
|
||||
return mapOf("pending", false, "items", slice, "shops", shops,
|
||||
"total", total, "page", page, "page_size", pageSize, "scanned_at", safe(view.scannedAt()));
|
||||
}
|
||||
|
||||
public Map<String, Object> detailData(DuplicateScanView view, Set<String> visibleKeys,
|
||||
int page, int pageSize, Filters filters) {
|
||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||
List<DuplicateItem> matched = filterItems(cropped, true, filters);
|
||||
matched.sort(detailItemComparator());
|
||||
int total = matched.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<Map<String, Object>> pageItems = new ArrayList<>();
|
||||
for (int i = from; i < to; i++) {
|
||||
pageItems.add(detailItem(matched.get(i)));
|
||||
}
|
||||
return mapOf("pending", false, "items", pageItems, "total", total,
|
||||
"page", page, "page_size", pageSize, "scanned_at", safe(view.scannedAt()));
|
||||
}
|
||||
|
||||
public Map<String, Object> pendingDetail(int page, int pageSize) {
|
||||
return mapOf("pending", true, "items", Collections.emptyList(), "total", 0,
|
||||
"page", page, "page_size", pageSize, "scanned_at", "");
|
||||
}
|
||||
|
||||
public void writeExport(DuplicateScanView view, Set<String> visibleKeys,
|
||||
String viewMode, Filters filters, OutputStream out) throws IOException {
|
||||
boolean monitor = "monitor".equals(viewMode);
|
||||
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
|
||||
List<DuplicateItem> matched = filterItems(cropped, monitor, filters);
|
||||
List<List<String>> rows = new ArrayList<>();
|
||||
rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "站点", "上架时间", "价格", "品牌"));
|
||||
List<Object[]> flat = new ArrayList<>();
|
||||
for (DuplicateItem item : matched) {
|
||||
String shopCount = String.valueOf(item.shopCount());
|
||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||
flat.add(new Object[]{item.asin(), shopCount, occ.shopName(), occ.groupName(),
|
||||
effectiveSite(occ), safe(occ.date()), safe(occ.price()), safe(occ.brand())});
|
||||
}
|
||||
}
|
||||
flat.sort(flatExportComparator());
|
||||
for (Object[] row : flat) {
|
||||
rows.add(toStringRow(row));
|
||||
}
|
||||
DuplicateCheckCsvWriter.write(out, rows);
|
||||
}
|
||||
|
||||
public List<DuplicateItem> filterItems(List<DuplicateItem> items, boolean monitor, Filters f) {
|
||||
List<DuplicateItem> matched = new ArrayList<>();
|
||||
for (DuplicateItem item : items) {
|
||||
if (monitor && item.shopCount() < 2) {
|
||||
continue;
|
||||
}
|
||||
if (matches(item, f)) {
|
||||
matched.add(item);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
private boolean matches(DuplicateItem item, Filters f) {
|
||||
String asin = item.asin() == null ? "" : item.asin();
|
||||
if (!f.asin().isEmpty() && !asin.contains(f.asin())) {
|
||||
return false;
|
||||
}
|
||||
boolean hasOccFilter = !f.shopName().isEmpty() || !f.country().isEmpty()
|
||||
|| !f.site().isEmpty() || !f.dateFrom().isEmpty() || !f.dateTo().isEmpty();
|
||||
if (!hasOccFilter) {
|
||||
return true;
|
||||
}
|
||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||
if (!occMatches(occ, f)) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean occMatches(DuplicateOccurrence occ, Filters f) {
|
||||
if (!f.shopName().isEmpty()) {
|
||||
String needle = f.shopName().toLowerCase(Locale.ROOT);
|
||||
String shop = safe(occ.shopName()).toLowerCase(Locale.ROOT);
|
||||
String group = safe(occ.groupName()).toLowerCase(Locale.ROOT);
|
||||
if (!shop.contains(needle) && !group.contains(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!f.country().isEmpty()) {
|
||||
boolean hit = false;
|
||||
for (String code : occ.countryCodes() == null ? List.<String>of() : occ.countryCodes()) {
|
||||
if (f.country().equals(safe(code).toUpperCase(Locale.ROOT))) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!f.site().isEmpty() && !f.site().equals(safe(occ.country()).toUpperCase(Locale.ROOT))) {
|
||||
return false;
|
||||
}
|
||||
String dateKey = DuplicateCheckTimeNormalizer.dateKey(safe(occ.date()));
|
||||
if (!f.dateFrom().isEmpty() && !dateKey.isEmpty() && dateKey.compareTo(f.dateFrom()) < 0) {
|
||||
return false;
|
||||
}
|
||||
if (!f.dateTo().isEmpty() && !dateKey.isEmpty() && dateKey.compareTo(f.dateTo()) > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** detail 卡片:occurrences 按归一化时间升序;brand=首个非空;first_date=最早可解析时间。 */
|
||||
private Map<String, Object> detailItem(DuplicateItem item) {
|
||||
List<DuplicateOccurrence> sorted = new ArrayList<>(item.occurrences());
|
||||
sorted.sort(Comparator.comparing(occ -> DuplicateCheckTimeNormalizer.normalizeSortTime(safe(occ.date()))));
|
||||
Set<String> shopNames = new LinkedHashSet<>();
|
||||
String brand = "";
|
||||
String firstDate = "";
|
||||
for (DuplicateOccurrence occ : sorted) {
|
||||
shopNames.add(occ.shopName() == null ? "" : occ.shopName());
|
||||
if (brand.isEmpty() && !safe(occ.brand()).isEmpty()) {
|
||||
brand = occ.brand();
|
||||
}
|
||||
if (firstDate.isEmpty()) {
|
||||
String normalized = DuplicateCheckTimeNormalizer.normalizeSortTime(safe(occ.date()));
|
||||
if (!normalized.isEmpty()) {
|
||||
firstDate = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("asin", item.asin());
|
||||
result.put("shop_count", item.shopCount());
|
||||
result.put("record_count", item.recordCount());
|
||||
result.put("shop_names", new ArrayList<>(shopNames));
|
||||
result.put("brand", brand);
|
||||
result.put("first_date", firstDate);
|
||||
result.put("occurrences", sorted);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Comparator<DuplicateItem> detailItemComparator() {
|
||||
return (a, b) -> {
|
||||
int byShops = Integer.compare(b.shopCount(), a.shopCount());
|
||||
if (byShops != 0) {
|
||||
return byShops;
|
||||
}
|
||||
long ra = earliestRank(a);
|
||||
long rb = earliestRank(b);
|
||||
if (ra != rb) {
|
||||
return Long.compare(rb, ra);
|
||||
}
|
||||
return safe(a.asin()).compareTo(safe(b.asin()));
|
||||
};
|
||||
}
|
||||
|
||||
/** 最早上架归一化时间戳(Asia/Shanghai 转 epoch 秒);无解析成功值返回 -1。 */
|
||||
private long earliestRank(DuplicateItem item) {
|
||||
String earliest = "";
|
||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||
String normalized = DuplicateCheckTimeNormalizer.normalizeSortTime(safe(occ.date()));
|
||||
if (!normalized.isEmpty() && (earliest.isEmpty() || normalized.compareTo(earliest) < 0)) {
|
||||
earliest = normalized;
|
||||
}
|
||||
}
|
||||
if (earliest.isEmpty()) {
|
||||
return -1L;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(earliest, SORT_FORMAT).atZone(SHANGHAI).toEpochSecond();
|
||||
} catch (Exception ex) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private DuplicateScanSummary recomputeSummary(List<DuplicateItem> items, List<DuplicateShop> shops, String source) {
|
||||
int recordTotal = 0;
|
||||
int duplicateAsinTotal = 0;
|
||||
Set<String> duplicateShops = new LinkedHashSet<>();
|
||||
Set<String> sites = new LinkedHashSet<>();
|
||||
for (DuplicateItem item : items) {
|
||||
recordTotal += item.recordCount();
|
||||
if (item.shopCount() >= 2) {
|
||||
duplicateAsinTotal++;
|
||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||
duplicateShops.add(occ.shopName() == null ? "" : occ.shopName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (DuplicateItem item : items) {
|
||||
for (DuplicateOccurrence occ : item.occurrences()) {
|
||||
if (!safe(occ.country()).isEmpty()) {
|
||||
sites.add(occ.country());
|
||||
}
|
||||
}
|
||||
}
|
||||
double asinPerShop = shops.isEmpty() ? 0.0
|
||||
: BigDecimal.valueOf((double) recordTotal / shops.size())
|
||||
.setScale(1, RoundingMode.HALF_EVEN).doubleValue();
|
||||
return new DuplicateScanSummary(shops.size(), items.size(), recordTotal,
|
||||
duplicateAsinTotal, duplicateShops.size(), sites.size(), asinPerShop,
|
||||
source == null ? "" : source);
|
||||
}
|
||||
|
||||
private String effectiveSite(DuplicateOccurrence occ) {
|
||||
if (!safe(occ.country()).isEmpty()) {
|
||||
return occ.country().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
List<String> codes = occ.countryCodes() == null ? List.of() : occ.countryCodes();
|
||||
if (codes.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String code : codes) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('、');
|
||||
}
|
||||
sb.append(safe(code).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private Comparator<Object[]> flatExportComparator() {
|
||||
return (a, b) -> {
|
||||
int shopA = Integer.parseInt((String) a[1]);
|
||||
int shopB = Integer.parseInt((String) b[1]);
|
||||
if (shopA != shopB) {
|
||||
return Integer.compare(shopB, shopA);
|
||||
}
|
||||
int byAsin = safe((String) a[0]).compareTo(safe((String) b[0]));
|
||||
if (byAsin != 0) {
|
||||
return byAsin;
|
||||
}
|
||||
int byShop = safe((String) a[2]).compareTo(safe((String) b[2]));
|
||||
if (byShop != 0) {
|
||||
return byShop;
|
||||
}
|
||||
return safe((String) a[5]).compareTo(safe((String) b[5]));
|
||||
};
|
||||
}
|
||||
|
||||
private List<String> toStringRow(Object[] row) {
|
||||
List<String> list = new ArrayList<>(row.length);
|
||||
for (Object value : row) {
|
||||
list.add(safe(value));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static String safe(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
private Map<String, Object> mapOf(Object... entries) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < entries.length; i += 2) {
|
||||
map.put((String) entries[i], entries[i + 1]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanFullRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanLightRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopGroupLabelDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopSourceRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 撞款扫描:每日 00:00 定时 + force 同步全量重扫。逐店取最新结果文件、拉取并解析 xlsx、
|
||||
* 聚合成 shops/items/summary 落库 shop_data_duplicate_scan(格式与 Python 现行版本一致)。
|
||||
* 双实例通过 Redis 分布式锁防重;扫描失败落 FAILED 行并上抛,force 场景由端点转 409/500。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ShopDataDuplicateCheckScanService {
|
||||
|
||||
public static final String SCAN_LOCK = "shop-data-duplicate-check:scan";
|
||||
static final DateTimeFormatter SCANNED_AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private static final long MAX_RESULT_BYTES = 256L * 1024 * 1024;
|
||||
private static final long MEM_CACHE_MAX_BYTES = 64L * 1024 * 1024;
|
||||
private static final int PARSE_THREADS = 4;
|
||||
private static final java.time.Duration SCAN_LOCK_TTL = java.time.Duration.ofHours(6);
|
||||
|
||||
private final ShopDataDuplicateScanMapper scanMapper;
|
||||
private final ShopDuplicateCheckSourceMapper sourceMapper;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** 进程内读缓存:轻量行 id 判活,行变化才全量读(对应 Python _duplicate_scan_memory)。 */
|
||||
private final AtomicLong cachedRowId = new AtomicLong(-1L);
|
||||
private volatile CachedScan cachedScan;
|
||||
|
||||
@Autowired
|
||||
public ShopDataDuplicateCheckScanService(ShopDataDuplicateScanMapper scanMapper,
|
||||
ShopDuplicateCheckSourceMapper sourceMapper,
|
||||
OssStorageService ossStorageService,
|
||||
DistributedJobLockService distributedJobLockService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.scanMapper = scanMapper;
|
||||
this.sourceMapper = sourceMapper;
|
||||
this.ossStorageService = ossStorageService;
|
||||
this.distributedJobLockService = distributedJobLockService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 读侧视图:scanned_at 为最新 SUCCESS 行 created_at(yyyy-MM-dd HH:mm:ss)。 */
|
||||
public record DuplicateScanView(String scannedAt, DuplicateScanSummary summary,
|
||||
DuplicateScanPayload payload) {
|
||||
}
|
||||
|
||||
private record CachedScan(long rowId, DuplicateScanView view) {
|
||||
}
|
||||
|
||||
/** force 端点调用:锁冲突抛 409,扫描失败落 FAILED 并抛 500。 */
|
||||
public void scanNow() {
|
||||
try (DistributedJobLockService.LockHandle lock = distributedJobLockService.tryLock(SCAN_LOCK, SCAN_LOCK_TTL)) {
|
||||
if (lock == null) {
|
||||
throw new BusinessException(409, "扫描进行中,请稍后刷新");
|
||||
}
|
||||
runScanAndSave(lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** 每日 00:00 定时(Asia/Shanghai);另一实例持锁时直接跳过,异常不向上抛。 */
|
||||
@Scheduled(cron = "${aiimage.shop-duplicate-check.scan-cron:0 0 0 * * *}")
|
||||
public void scheduledScan() {
|
||||
try (DistributedJobLockService.LockHandle lock = distributedJobLockService.tryLock(SCAN_LOCK, SCAN_LOCK_TTL)) {
|
||||
if (lock == null) {
|
||||
log.info("[shop-duplicate-check] 定时扫描被其它实例执行,本实例跳过");
|
||||
return;
|
||||
}
|
||||
runScanAndSave(lock);
|
||||
} catch (BusinessException ex) {
|
||||
log.warn("[shop-duplicate-check] 定时扫描失败: code={} msg={}", ex.getCode(), ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
log.error("[shop-duplicate-check] 定时扫描异常", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 最新 SUCCESS 扫描视图;无扫描结果返回 null。 */
|
||||
public DuplicateScanView loadLatest() {
|
||||
ScanLightRowDto light = scanMapper.selectLatestLightRow();
|
||||
long lightId = light == null || light.getId() == null ? -1L : light.getId();
|
||||
CachedScan cached = cachedScan;
|
||||
if (lightId == cachedRowId.get() && cached != null) {
|
||||
return cached.view();
|
||||
}
|
||||
if (light == null) {
|
||||
cachedScan = null;
|
||||
cachedRowId.set(-1L);
|
||||
return null;
|
||||
}
|
||||
ScanFullRowDto full = scanMapper.selectLatestFullRow();
|
||||
if (full == null || full.getPayloadJson() == null || full.getPayloadJson().isBlank()) {
|
||||
cachedScan = null;
|
||||
cachedRowId.set(lightId);
|
||||
return null;
|
||||
}
|
||||
DuplicateScanView view = buildView(full);
|
||||
if (view == null) {
|
||||
cachedScan = null;
|
||||
cachedRowId.set(lightId);
|
||||
return null;
|
||||
}
|
||||
long payloadBytes = full.getPayloadJson().getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
if (payloadBytes <= MEM_CACHE_MAX_BYTES) {
|
||||
cachedScan = new CachedScan(lightId, view);
|
||||
cachedRowId.set(lightId);
|
||||
} else {
|
||||
// payload 过大不常驻内存,每次请求读库(等价 Python 64MB 阈值语义)
|
||||
cachedScan = null;
|
||||
cachedRowId.set(-1L);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
private DuplicateScanView buildView(ScanFullRowDto full) {
|
||||
try {
|
||||
DuplicateScanSummary summary = objectMapper.readValue(full.getSummaryJson(), DuplicateScanSummary.class);
|
||||
JsonNode payloadNode = objectMapper.readTree(full.getPayloadJson());
|
||||
if (payloadNode == null || !payloadNode.isObject() || !payloadNode.has("shops") || !payloadNode.has("items")) {
|
||||
// 兼容性兜底:旧版 list payload 不解析,视为无结果并告警
|
||||
log.warn("[shop-duplicate-check] 忽略无法解析的 payload 行 id={}", full.getId());
|
||||
return null;
|
||||
}
|
||||
DuplicateScanPayload payload = objectMapper.treeToValue(payloadNode, DuplicateScanPayload.class);
|
||||
String scannedAt = full.getCreatedAt() == null ? ""
|
||||
: full.getCreatedAt().format(SCANNED_AT_FORMAT);
|
||||
return new DuplicateScanView(scannedAt, summary, payload);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-duplicate-check] 解析最新扫描行失败 id={} msg={}", full.getId(), ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void runScanAndSave(DistributedJobLockService.LockHandle lock) {
|
||||
try {
|
||||
List<ShopParsed> parsedShops = collectShopParsed(lock);
|
||||
DuplicateCheckAggregator.Aggregate aggregate =
|
||||
DuplicateCheckAggregator.aggregate(parsedShops, "job");
|
||||
ShopDataDuplicateScanEntity row = new ShopDataDuplicateScanEntity();
|
||||
row.setStatus("SUCCESS");
|
||||
row.setErrorText(null);
|
||||
row.setFinishedAt(LocalDateTime.now());
|
||||
try {
|
||||
row.setSummaryJson(objectMapper.writeValueAsString(aggregate.summary()));
|
||||
row.setPayloadJson(objectMapper.writeValueAsString(aggregate.payload()));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("扫描结果序列化失败: " + ex.getMessage(), ex);
|
||||
}
|
||||
scanMapper.insert(row);
|
||||
log.info("[shop-duplicate-check] 扫描完成 落库行 id={} 店铺数={} ASIN数={} 记录数={}",
|
||||
row.getId(), aggregate.summary().shopCount(),
|
||||
aggregate.summary().asinTotal(), aggregate.summary().recordTotal());
|
||||
} catch (BusinessException ex) {
|
||||
saveFailed(ex.getMessage());
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("[shop-duplicate-check] 扫描失败", ex);
|
||||
saveFailed(ex.getMessage() == null ? ex.toString() : ex.getMessage());
|
||||
throw new BusinessException(500, "执行失败:" + fallbackMessage(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private void saveFailed(String errorText) {
|
||||
try {
|
||||
ShopDataDuplicateScanEntity row = new ShopDataDuplicateScanEntity();
|
||||
row.setStatus("FAILED");
|
||||
row.setErrorText(errorText);
|
||||
row.setFinishedAt(LocalDateTime.now());
|
||||
scanMapper.insert(row);
|
||||
} catch (Exception ex) {
|
||||
log.error("[shop-duplicate-check] 记录 FAILED 扫描行失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String fallbackMessage(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
/** 逐店并发拉取/解析结果文件;单店失败仅告警跳过,不中断整体。 */
|
||||
private List<ShopParsed> collectShopParsed(DistributedJobLockService.LockHandle lock) {
|
||||
List<ShopSourceRowDto> rows = sourceMapper.selectLatestResultRows();
|
||||
List<ShopParsed> parsedShops = new ArrayList<>();
|
||||
if (rows.isEmpty()) {
|
||||
return parsedShops;
|
||||
}
|
||||
Map<String, String> groupLabels = loadGroupLabels(rows);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(Math.min(PARSE_THREADS, rows.size()));
|
||||
try {
|
||||
List<Future<ShopParsed>> futures = new ArrayList<>(rows.size());
|
||||
for (ShopSourceRowDto row : rows) {
|
||||
futures.add(executor.submit(() -> parseShop(row, groupLabels)));
|
||||
}
|
||||
for (Future<ShopParsed> future : futures) {
|
||||
if (lock != null) {
|
||||
lock.renew(SCAN_LOCK_TTL);
|
||||
}
|
||||
try {
|
||||
ShopParsed item = future.get();
|
||||
if (item != null) {
|
||||
parsedShops.add(item);
|
||||
}
|
||||
} catch (ExecutionException ex) {
|
||||
log.warn("[shop-duplicate-check] 并发解析店铺结果文件异常: {}", ex.getCause() == null
|
||||
? ex.getMessage() : ex.getCause().getMessage());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[shop-duplicate-check] 解析线程被中断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
executor.shutdown();
|
||||
try {
|
||||
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
return parsedShops;
|
||||
}
|
||||
|
||||
private Map<String, String> loadGroupLabels(List<ShopSourceRowDto> rows) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (ShopSourceRowDto row : rows) {
|
||||
String name = normalizeShopName(row.getShopName());
|
||||
if (!name.isEmpty()) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
Map<String, String> labels = new HashMap<>();
|
||||
if (names.isEmpty()) {
|
||||
return labels;
|
||||
}
|
||||
for (ShopGroupLabelDto dto : sourceMapper.selectGroupLabels(new ArrayList<>(names))) {
|
||||
labels.put(shopKey(dto.getShopName()), dto.getGroupName() == null ? "" : dto.getGroupName());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private ShopParsed parseShop(ShopSourceRowDto row, Map<String, String> groupLabels) {
|
||||
long resultId = row.getResultId() == null ? 0L : row.getResultId();
|
||||
if (resultId <= 0) {
|
||||
return null;
|
||||
}
|
||||
String shopName = normalizeShopName(row.getShopName());
|
||||
if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (row.getResultFileSize() != null && row.getResultFileSize() > MAX_RESULT_BYTES) {
|
||||
log.warn("[shop-data-crawl] 结果文件过大跳过 result_id={} size={}", resultId, row.getResultFileSize());
|
||||
return null;
|
||||
}
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = ossStorageService.readObjectBytesBounded(row.getResultFileUrl(), MAX_RESULT_BYTES);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn("[shop-data-crawl] 结果文件过大跳过 result_id={}: {}", resultId, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
List<com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow> parsed;
|
||||
try {
|
||||
parsed = DuplicateCheckWorkbookParser.parse(bytes);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-data-crawl] 解析结果文件失败 result_id={}: {}", resultId,
|
||||
ex.getMessage() == null ? ex.toString() : ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
List<String> countryCodes = parseCountryCodesJson(row.getCountryCodesJson());
|
||||
if (countryCodes.isEmpty()) {
|
||||
countryCodes = parseCountryCodesRequest(row.getRequestJson());
|
||||
}
|
||||
String groupName = groupLabels.getOrDefault(shopKey(shopName), "");
|
||||
return new ShopParsed(shopName, groupName, countryCodes, parsed);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-data-crawl] 拉取结果文件失败 result_id={}: {}", resultId,
|
||||
ex.getMessage() == null ? ex.toString() : ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeShopName(String raw) {
|
||||
return raw == null ? "未命名" : (raw.trim().isEmpty() ? "未命名" : raw.trim());
|
||||
}
|
||||
|
||||
private String shopKey(String name) {
|
||||
return (name == null ? "" : name.trim()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private List<String> parseCountryCodesJson(String jsonValue) {
|
||||
if (jsonValue == null || jsonValue.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(jsonValue);
|
||||
if (node != null && node.isArray()) {
|
||||
return normalizeCodes(node);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-data-crawl] 解析 country_codes_json 失败: {}", ex.getMessage());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> parseCountryCodesRequest(String requestJson) {
|
||||
if (requestJson == null || requestJson.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(requestJson);
|
||||
if (node == null || !node.isObject()) {
|
||||
return List.of();
|
||||
}
|
||||
JsonNode raw = node.get("countryCodes");
|
||||
if (raw == null) {
|
||||
raw = node.get("country_codes");
|
||||
}
|
||||
if (raw != null && raw.isArray()) {
|
||||
return normalizeCodes(raw);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-data-crawl] 解析 request_json 国家代码失败: {}", ex.getMessage());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> normalizeCodes(JsonNode array) {
|
||||
List<String> codes = new ArrayList<>();
|
||||
for (JsonNode item : array) {
|
||||
String value = item.asText();
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
codes.add(value.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 等价 Python _build_duplicate_scan_cache:把各店解析行聚合成 shops/items/summary。
|
||||
* items 按 (-shop_count, asin) 排序;shops 按 (-asin_count, shop_name) 排序;summary 全字段重算。
|
||||
*/
|
||||
public final class DuplicateCheckAggregator {
|
||||
|
||||
private static final Comparator<DuplicateItem> ITEM_ORDER =
|
||||
Comparator.comparingInt(DuplicateItem::shopCount).reversed()
|
||||
.thenComparing(DuplicateItem::asin);
|
||||
private static final Comparator<DuplicateShop> SHOP_ORDER =
|
||||
Comparator.comparingInt(DuplicateShop::asinCount).reversed()
|
||||
.thenComparing(DuplicateShop::shopName);
|
||||
|
||||
private DuplicateCheckAggregator() {
|
||||
}
|
||||
|
||||
/** 单店聚合元信息:分组名 + 店铺级国家代码。 */
|
||||
private record ShopMeta(String groupName, List<String> countryCodes) {
|
||||
}
|
||||
|
||||
public record Aggregate(DuplicateScanPayload payload, DuplicateScanSummary summary) {
|
||||
}
|
||||
|
||||
public static Aggregate aggregate(List<ShopParsed> shopItems, String source) {
|
||||
Map<String, List<DuplicateOccurrence>> occurrencesByAsin = new LinkedHashMap<>();
|
||||
Map<String, ShopMeta> shopMeta = new LinkedHashMap<>();
|
||||
|
||||
for (ShopParsed shopItem : shopItems) {
|
||||
String shopName = (shopItem.shopName() == null || shopItem.shopName().isBlank())
|
||||
? "未命名" : shopItem.shopName().trim();
|
||||
String groupName = shopItem.groupName() == null ? "" : shopItem.groupName();
|
||||
List<String> countryCodes = shopItem.countryCodes() == null
|
||||
? List.of() : List.copyOf(shopItem.countryCodes());
|
||||
shopMeta.put(shopName, new ShopMeta(groupName, countryCodes));
|
||||
for (RawRow row : shopItem.rows()) {
|
||||
String asin = (row.asin() == null ? "" : row.asin()).trim().toUpperCase();
|
||||
if (asin.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
occurrencesByAsin.computeIfAbsent(asin, key -> new ArrayList<>()).add(
|
||||
new DuplicateOccurrence(
|
||||
asin,
|
||||
row.date() == null ? "" : row.date(),
|
||||
row.price() == null ? "" : row.price(),
|
||||
row.brand() == null ? "" : row.brand(),
|
||||
shopName,
|
||||
groupName,
|
||||
countryCodes,
|
||||
row.country() == null ? "" : row.country()));
|
||||
}
|
||||
}
|
||||
|
||||
List<DuplicateItem> items = new ArrayList<>(occurrencesByAsin.size());
|
||||
int totalRecords = 0;
|
||||
for (Map.Entry<String, List<DuplicateOccurrence>> entry : occurrencesByAsin.entrySet()) {
|
||||
List<DuplicateOccurrence> occurrences = entry.getValue();
|
||||
Set<String> shops = new LinkedHashSet<>();
|
||||
for (DuplicateOccurrence occurrence : occurrences) {
|
||||
shops.add(occurrence.shopName());
|
||||
}
|
||||
totalRecords += occurrences.size();
|
||||
items.add(new DuplicateItem(entry.getKey(), shops.size(), occurrences.size(), occurrences));
|
||||
}
|
||||
items.sort(ITEM_ORDER);
|
||||
|
||||
List<DuplicateShop> shops = new ArrayList<>(shopMeta.size());
|
||||
for (Map.Entry<String, ShopMeta> entry : shopMeta.entrySet()) {
|
||||
String shopName = entry.getKey();
|
||||
ShopMeta meta = entry.getValue();
|
||||
Set<String> asinSet = new LinkedHashSet<>();
|
||||
int recordCount = 0;
|
||||
for (List<DuplicateOccurrence> occurrences : occurrencesByAsin.values()) {
|
||||
for (DuplicateOccurrence occurrence : occurrences) {
|
||||
if (occurrence.shopName().equals(shopName)) {
|
||||
asinSet.add(occurrence.asin());
|
||||
recordCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
shops.add(new DuplicateShop(shopName, meta.groupName(), meta.countryCodes(),
|
||||
asinSet.size(), recordCount));
|
||||
}
|
||||
shops.sort(SHOP_ORDER);
|
||||
|
||||
int duplicateAsinTotal = 0;
|
||||
Set<String> duplicateShops = new LinkedHashSet<>();
|
||||
Set<String> sites = new LinkedHashSet<>();
|
||||
for (DuplicateItem item : items) {
|
||||
if (item.shopCount() >= 2) {
|
||||
duplicateAsinTotal++;
|
||||
for (DuplicateOccurrence occurrence : item.occurrences()) {
|
||||
duplicateShops.add(occurrence.shopName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (List<DuplicateOccurrence> occurrences : occurrencesByAsin.values()) {
|
||||
for (DuplicateOccurrence occurrence : occurrences) {
|
||||
if (!occurrence.country().isEmpty()) {
|
||||
sites.add(occurrence.country());
|
||||
}
|
||||
}
|
||||
}
|
||||
double asinPerShop = shops.isEmpty() ? 0.0 : roundToScale1((double) totalRecords / shops.size());
|
||||
DuplicateScanSummary summary = new DuplicateScanSummary(
|
||||
shops.size(), items.size(), totalRecords, duplicateAsinTotal,
|
||||
duplicateShops.size(), sites.size(), asinPerShop,
|
||||
source == null ? "job" : source);
|
||||
return new Aggregate(new DuplicateScanPayload(shops, items), summary);
|
||||
}
|
||||
|
||||
private static double roundToScale1(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(1, RoundingMode.HALF_EVEN).doubleValue();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 导出 CSV:UTF-8 BOM + CRLF + QUOTE_MINIMAL 转义(含 , " \r \n 时双引号包裹、内部 " 翻倍),
|
||||
* 等价 Python csv.writer 默认行为。
|
||||
*/
|
||||
public final class DuplicateCheckCsvWriter {
|
||||
|
||||
private DuplicateCheckCsvWriter() {
|
||||
}
|
||||
|
||||
public static void write(OutputStream out, List<List<String>> rows) throws IOException {
|
||||
out.write(0xEF);
|
||||
out.write(0xBB);
|
||||
out.write(0xBF);
|
||||
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
||||
StringBuilder line = new StringBuilder();
|
||||
for (List<String> row : rows) {
|
||||
line.setLength(0);
|
||||
for (int i = 0; i < row.size(); i++) {
|
||||
if (i > 0) {
|
||||
line.append(',');
|
||||
}
|
||||
appendField(line, row.get(i));
|
||||
}
|
||||
line.append("\r\n");
|
||||
writer.write(line.toString());
|
||||
}
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
private static void appendField(StringBuilder target, String value) {
|
||||
String text = value == null ? "" : value;
|
||||
boolean needQuote = text.indexOf(',') >= 0
|
||||
|| text.indexOf('"') >= 0
|
||||
|| text.indexOf('\r') >= 0
|
||||
|| text.indexOf('\n') >= 0;
|
||||
if (!needQuote) {
|
||||
target.append(text);
|
||||
return;
|
||||
}
|
||||
target.append('"');
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char ch = text.charAt(i);
|
||||
if (ch == '"') {
|
||||
target.append('"');
|
||||
}
|
||||
target.append(ch);
|
||||
}
|
||||
target.append('"');
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 复刻 Python 后台 _shop_data_dup_sort_time / _shop_data_date_key 的逐字语义:
|
||||
* 中文日期/上午下午/斜杠点格式归一化。输出固定宽度 YYYY-MM-DD HH:MM:SS 或
|
||||
* YYYY-MM-DD 字符串,直接字符串比较即时间比较;无法解析时原样返回(排最后)。
|
||||
*/
|
||||
public final class DuplicateCheckTimeNormalizer {
|
||||
|
||||
private static final DateTimeFormatter SORT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private static final Pattern ISO_TIME = Pattern.compile("([0-9]{1,2})\\s*[::]\\s*([0-9]{2})(?:[::]\\s*([0-9]{1,2}))?");
|
||||
private static final Pattern PERIOD_TIME = Pattern.compile(
|
||||
"(上午|下午|晚上|凌晨)?\\s*([0-9]{1,2})\\s*[::]\\s*([0-9]{2})(?:[::]\\s*([0-9]{1,2}))?");
|
||||
private static final Pattern CHINESE_DATE = Pattern.compile("([0-9]{4})\\s*年\\s*([0-9]{1,2})\\s*月\\s*([0-9]{1,2})\\s*日\\s*(\\S*)?");
|
||||
private static final Pattern SLASH_DATE = Pattern.compile("([0-9]{4})[./]([0-9]{1,2})[./]([0-9]{1,2})(.*)");
|
||||
private static final Pattern SEPARATED_DATE = Pattern.compile("([0-9]{4})\\s*[年./-]\\s*([0-9]{1,2})\\s*[月./-]\\s*([0-9]{1,2})");
|
||||
|
||||
private DuplicateCheckTimeNormalizer() {
|
||||
}
|
||||
|
||||
/** 等价 Python _shop_data_dup_sort_time:19 位排序时间,无法解析原样返回。 */
|
||||
public static String normalizeSortTime(String raw) {
|
||||
String text = raw == null ? "" : raw.trim();
|
||||
if (text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
// ISO 风格:2026-08-18 04:34:00 / 2026-08-18 04:34 / 2026-08-18
|
||||
if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
|
||||
String base = text.substring(0, 10);
|
||||
Matcher tm = ISO_TIME.matcher(text.substring(10));
|
||||
if (tm.find()) {
|
||||
int hour = Integer.parseInt(tm.group(1));
|
||||
int minute = Integer.parseInt(tm.group(2));
|
||||
int second = tm.group(3) == null ? 0 : Integer.parseInt(tm.group(3));
|
||||
LocalDateTime time = tryLocalDateTime(base, hour, minute, second);
|
||||
if (time != null) {
|
||||
return time.format(SORT_FORMAT);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return base + " 00:00:00";
|
||||
}
|
||||
// 中文日期:2026年8月29日 上午4:34 / 下午2:15 / 晚上8:00
|
||||
Matcher m = CHINESE_DATE.matcher(text);
|
||||
if (m.find()) {
|
||||
int year = Integer.parseInt(m.group(1));
|
||||
int month = Integer.parseInt(m.group(2));
|
||||
int day = Integer.parseInt(m.group(3));
|
||||
String periodText = m.group(4) == null ? "" : m.group(4).trim();
|
||||
int hour = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
Matcher tm = PERIOD_TIME.matcher(periodText);
|
||||
if (tm.find()) {
|
||||
hour = Integer.parseInt(tm.group(2));
|
||||
minute = Integer.parseInt(tm.group(3));
|
||||
second = tm.group(4) == null ? 0 : Integer.parseInt(tm.group(4));
|
||||
String period = tm.group(1) == null ? "" : tm.group(1);
|
||||
if (("下午".equals(period) || "晚上".equals(period)) && hour < 12) {
|
||||
hour += 12;
|
||||
}
|
||||
if (("上午".equals(period) || "凌晨".equals(period)) && hour == 12) {
|
||||
hour = 0;
|
||||
}
|
||||
}
|
||||
LocalDateTime time = tryLocalDateTime(year, month, day, hour, minute, second);
|
||||
if (time != null) {
|
||||
return time.format(SORT_FORMAT);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
// 斜杠/点分隔日期:2026/8/18 4:34(无时段换算)
|
||||
Matcher dm = SLASH_DATE.matcher(text);
|
||||
if (dm.find()) {
|
||||
int year = Integer.parseInt(dm.group(1));
|
||||
int month = Integer.parseInt(dm.group(2));
|
||||
int day = Integer.parseInt(dm.group(3));
|
||||
int hour = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
Matcher tm = ISO_TIME.matcher(dm.group(4) == null ? "" : dm.group(4));
|
||||
if (tm.find()) {
|
||||
hour = Integer.parseInt(tm.group(1));
|
||||
minute = Integer.parseInt(tm.group(2));
|
||||
second = tm.group(3) == null ? 0 : Integer.parseInt(tm.group(3));
|
||||
}
|
||||
LocalDateTime time = tryLocalDateTime(year, month, day, hour, minute, second);
|
||||
if (time != null) {
|
||||
return time.format(SORT_FORMAT);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 等价 Python _shop_data_date_key:YYYY-MM-DD 比较键,无法识别原样返回。 */
|
||||
public static String dateKey(String dateText) {
|
||||
String text = dateText == null ? "" : dateText.trim();
|
||||
if (text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (text.length() >= 10 && text.charAt(4) == '-' && text.charAt(7) == '-') {
|
||||
return text.substring(0, 10);
|
||||
}
|
||||
Matcher m = SEPARATED_DATE.matcher(text);
|
||||
if (m.find()) {
|
||||
int year = Integer.parseInt(m.group(1));
|
||||
int month = Integer.parseInt(m.group(2));
|
||||
int day = Integer.parseInt(m.group(3));
|
||||
// Python 侧仅做 int + f-string 格式化、不校验月日合法性,此处保持一致
|
||||
return String.format("%04d-%02d-%02d", year, month, day);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static LocalDateTime tryLocalDateTime(String isoBase, int hour, int minute, int second) {
|
||||
try {
|
||||
int year = Integer.parseInt(isoBase.substring(0, 4));
|
||||
int month = Integer.parseInt(isoBase.substring(5, 7));
|
||||
int day = Integer.parseInt(isoBase.substring(8, 10));
|
||||
return LocalDateTime.of(year, month, day, hour, minute, second);
|
||||
} catch (RuntimeException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime tryLocalDateTime(int year, int month, int day, int hour, int minute, int second) {
|
||||
try {
|
||||
return LocalDateTime.of(year, month, day, hour, minute, second);
|
||||
} catch (RuntimeException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import com.nanri.aiimage.common.util.ExcelStreamReader;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 解析店铺结果 xlsx 为 ASIN 行:第 1 行表头、必须有 ASIN 列否则整 sheet 跳过、
|
||||
* 可选列 日期/价格/品牌、sheet 名(小写精确)映射国家码。语义等价 Python
|
||||
* _shop_data_crawl_parse_workbook + _shop_data_sheet_country。失败由上层捕获(不中断整体扫描)。
|
||||
*/
|
||||
public final class DuplicateCheckWorkbookParser {
|
||||
|
||||
private static final Map<String, String> SHEET_COUNTRY = buildSheetCountryMap();
|
||||
|
||||
private DuplicateCheckWorkbookParser() {
|
||||
}
|
||||
|
||||
public static List<RawRow> parse(byte[] bytes) throws Exception {
|
||||
Parser handler = new Parser();
|
||||
ExcelStreamReader.readAllSheets(new ByteArrayInputStream(bytes), handler);
|
||||
return handler.rows;
|
||||
}
|
||||
|
||||
private static Map<String, String> buildSheetCountryMap() {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
map.put("英国", "UK");
|
||||
map.put("uk", "UK");
|
||||
map.put("u.k.", "UK");
|
||||
map.put("united kingdom", "UK");
|
||||
map.put("德国", "DE");
|
||||
map.put("de", "DE");
|
||||
map.put("germany", "DE");
|
||||
map.put("法国", "FR");
|
||||
map.put("fr", "FR");
|
||||
map.put("france", "FR");
|
||||
map.put("意大利", "IT");
|
||||
map.put("it", "IT");
|
||||
map.put("italy", "IT");
|
||||
map.put("西班牙", "ES");
|
||||
map.put("es", "ES");
|
||||
map.put("spain", "ES");
|
||||
return map;
|
||||
}
|
||||
|
||||
private static final class Parser implements ExcelStreamReader.SheetRowHandler {
|
||||
|
||||
private final List<RawRow> rows = new ArrayList<>();
|
||||
private int asinCol = -1;
|
||||
private int dateCol = -1;
|
||||
private int priceCol = -1;
|
||||
private int brandCol = -1;
|
||||
private String sheetCountry = "";
|
||||
|
||||
@Override
|
||||
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
|
||||
asinCol = -1;
|
||||
dateCol = -1;
|
||||
priceCol = -1;
|
||||
brandCol = -1;
|
||||
sheetCountry = countryOf(sheetName);
|
||||
if (headerMap == null) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
|
||||
String header = cell(entry.getValue());
|
||||
int index = entry.getKey();
|
||||
if ("ASIN".equals(header)) {
|
||||
asinCol = index;
|
||||
} else if ("日期".equals(header)) {
|
||||
dateCol = index;
|
||||
} else if ("价格".equals(header)) {
|
||||
priceCol = index;
|
||||
} else if ("品牌".equals(header)) {
|
||||
brandCol = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRow(String sheetName, Integer sheetNo, int rowIndex,
|
||||
Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
|
||||
if (asinCol < 0) {
|
||||
return;
|
||||
}
|
||||
String asin = cell(rowMap == null ? null : rowMap.get(asinCol));
|
||||
if (asin.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
rows.add(new RawRow(
|
||||
asin,
|
||||
cell(rowMap.get(dateCol)),
|
||||
cell(rowMap.get(priceCol)),
|
||||
cell(rowMap.get(brandCol)),
|
||||
sheetCountry));
|
||||
}
|
||||
}
|
||||
|
||||
private static String countryOf(String sheetName) {
|
||||
String key = (sheetName == null ? "" : sheetName).trim().toLowerCase();
|
||||
return SHEET_COUNTRY.getOrDefault(key, "");
|
||||
}
|
||||
|
||||
private static String cell(Object value) {
|
||||
return value == null ? "" : value.toString().trim();
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
/** 单店结果文件解析出的一行:asin 大写处理在聚合阶段完成。 */
|
||||
public record RawRow(String asin, String date, String price, String brand, String country) {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 单店参与扫描的最小单元:店铺归属 + 解析行列表。 */
|
||||
public record ShopParsed(String shopName, String groupName, List<String> countryCodes, List<RawRow> rows) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 店铺数据重复检查(撞款)扫描结果表:Java 侧等价于 Python 的 _ensure_duplicate_scan_table。
|
||||
-- 生产库该表已由 Python 侧创建,IF NOT EXISTS 保证幂等(Flyway 只追加、不改历史)。
|
||||
CREATE TABLE IF NOT EXISTS shop_data_duplicate_scan (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
error_text TEXT NULL,
|
||||
summary_json MEDIUMTEXT NULL,
|
||||
payload_json MEDIUMTEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME NULL,
|
||||
KEY idx_status_created (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -62,6 +62,44 @@ class AdminApiGuardFilterTest {
|
||||
assertThat(response.getContentAsString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedInternalTokenBypassesGuardButReachesController() throws Exception {
|
||||
// 仅凭 X-Internal-Token 的合法内部调用(如 shop-manages/credential 由 controller 自校验)
|
||||
// 应被 guard 放行,而不是拦成 401 —— 修复客户端带令牌却被 guard 误拦的故障。
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
when(authSupport.isTrustedInternalToken(any())).thenReturn(true);
|
||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/shop-manages/credential");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(authSupport, never()).requireUserOrInternal(any());
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
assertThat(response.getContentAsString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void untrustedTokenStillRejected() throws Exception {
|
||||
// 令牌不匹配(X-Internal-Token 错值/泄露)时 guard 仍然拦截
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
when(authSupport.isTrustedInternalToken(any())).thenReturn(false);
|
||||
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 anonymousAdminApiRequestRejectedWith401Body() throws Exception {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ class AdminUserServiceTest {
|
||||
request.setColumnIds(List.of(11L, 12L));
|
||||
|
||||
when(authSupport.currentRole(operator)).thenReturn("admin");
|
||||
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
|
||||
when(passwordEncoder.hash("secret1")).thenReturn("hashed");
|
||||
when(userMapper.insert(any(AdminUserEntity.class))).thenAnswer(invocation -> {
|
||||
invocation.<AdminUserEntity>getArgument(0).setId(20L);
|
||||
@@ -73,6 +74,7 @@ class AdminUserServiceTest {
|
||||
request.setColumnIds(List.of(21L));
|
||||
|
||||
when(authSupport.currentRole(operator)).thenReturn("admin");
|
||||
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
|
||||
when(userMapper.selectById(20L)).thenReturn(target);
|
||||
|
||||
service.updateUser(operator, 20L, request);
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 撞款测试共享夹具:4 家店 / 3 组 / 6 个 ASIN,与 Python test_admin_shop_data_duplicate_check.py 的
|
||||
* 分布一致。经 DuplicateCheckAggregator 聚合出真实 payload/summary(shops/items 按扫描排序规则落序)。
|
||||
*/
|
||||
public final class TestFixtures {
|
||||
|
||||
private TestFixtures() {
|
||||
}
|
||||
|
||||
public static List<ShopParsed> shopParsed() {
|
||||
ShopParsed shopA = new ShopParsed("ShopA", "GroupA", List.of("UK", "DE"), List.of(
|
||||
new RawRow("A0000001", "2026-08-30", "GBP 9.99", "BrandA", "UK"),
|
||||
new RawRow("B0000001", "2026-08-30", "EUR 10.99", "BrandA", "DE"),
|
||||
new RawRow("E0000001", "2026年8月29日 上午4:34", "GBP 12.00", "BrandE", "UK")));
|
||||
ShopParsed shopB = new ShopParsed("ShopB", "GroupA", List.of("UK"), List.of(
|
||||
new RawRow("A0000001", "2026-08-30 08:30:00", "GBP 8.50", "BrandA", "UK"),
|
||||
new RawRow("E0000001", "2026-08-30", "GBP 12.00", "BrandE", "UK"),
|
||||
new RawRow("F0000001", "2026-08-28", "GBP 6.00", "BrandF", "UK")));
|
||||
ShopParsed shopC = new ShopParsed("ShopC", "GroupC", List.of("FR"), List.of(
|
||||
new RawRow("C0000001", "2026-08-29", "EUR 7.50", "BrandC", "FR"),
|
||||
new RawRow("E0000001", "2026-08-31", "EUR 12.00", "BrandE", "FR")));
|
||||
ShopParsed shopD = new ShopParsed("ShopD", "GroupD", List.of("UK"), List.of(
|
||||
new RawRow("D0000001", "2026-08-28", "GBP 5.00", "BrandD", "UK"),
|
||||
new RawRow("F0000001", "2026-08-30", "GBP 6.00", "BrandF", "UK")));
|
||||
return List.of(shopA, shopB, shopC, shopD);
|
||||
}
|
||||
|
||||
public static DuplicateScanPayload payload() {
|
||||
return DuplicateCheckAggregator.aggregate(shopParsed(), "job").payload();
|
||||
}
|
||||
|
||||
public static DuplicateScanView view() {
|
||||
DuplicateCheckAggregator.Aggregate aggregate = DuplicateCheckAggregator.aggregate(shopParsed(), "job");
|
||||
return new DuplicateScanView("2026-09-04 03:10:00", aggregate.summary(), aggregate.payload());
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.TestFixtures;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ShopDataDuplicateCheckQueryServiceTest {
|
||||
|
||||
private ShopDataDuplicateCheckQueryService service;
|
||||
private ShopDataDuplicateCheckScanService.DuplicateScanView view;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ShopDuplicateCheckSourceMapper mapper = mock(ShopDuplicateCheckSourceMapper.class);
|
||||
service = new ShopDataDuplicateCheckQueryService(mapper);
|
||||
view = TestFixtures.view();
|
||||
}
|
||||
|
||||
private ShopDataDuplicateCheckQueryService.Filters noFilter() {
|
||||
return ShopDataDuplicateCheckQueryService.Filters.clean("", "", "", "", "", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewSuperAdminSeesAll() {
|
||||
Map<String, Object> data = service.overviewData(view, null);
|
||||
assertThat(data.get("pending")).isEqualTo(false);
|
||||
assertThat(data.get("scanned_at")).isEqualTo("2026-09-04 03:10:00");
|
||||
DuplicateScanSummary summary = (DuplicateScanSummary) data.get("summary");
|
||||
assertThat(summary.asinTotal()).isEqualTo(6);
|
||||
assertThat(summary.recordTotal()).isEqualTo(10);
|
||||
assertThat(summary.duplicateAsinTotal()).isEqualTo(3);
|
||||
assertThat(summary.shopCount()).isEqualTo(4);
|
||||
assertThat(((List<?>) data.get("shops"))).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderOverviewStatsRecomputedAfterCrop() {
|
||||
Set<String> visible = Set.of("shopa", "shopb");
|
||||
Map<String, Object> data = service.overviewData(view, visible);
|
||||
DuplicateScanSummary summary = (DuplicateScanSummary) data.get("summary");
|
||||
assertThat(summary.shopCount()).isEqualTo(2);
|
||||
assertThat(summary.asinTotal()).isEqualTo(4);
|
||||
assertThat(summary.recordTotal()).isEqualTo(6);
|
||||
assertThat(summary.duplicateAsinTotal()).isEqualTo(2);
|
||||
assertThat(summary.duplicateShopCount()).isEqualTo(2);
|
||||
assertThat(summary.siteCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderSeesOnlyOwnGroupShops() {
|
||||
Set<String> visible = Set.of("shopa", "shopb");
|
||||
Map<String, Object> data = service.itemsData(view, visible, 1, 20, "all", noFilter());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateShop> shops = (List<DuplicateShop>) data.get("shops");
|
||||
assertThat(shops.stream().map(DuplicateShop::shopName).toList())
|
||||
.containsExactly("ShopA", "ShopB");
|
||||
assertThat(data.get("total")).isEqualTo(4);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateItem> items = (List<DuplicateItem>) data.get("items");
|
||||
DuplicateItem a = items.stream().filter(item -> "A0000001".equals(item.asin())).findFirst().orElseThrow();
|
||||
assertThat(a.shopCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderSeesNothingWithoutAnyGroup() {
|
||||
Map<String, Object> data = service.itemsData(view, Set.of(), 1, 20, "all", noFilter());
|
||||
assertThat(data.get("shops")).isEqualTo(List.of());
|
||||
assertThat(data.get("total")).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void monitorOnlyKeepsRepeatedAsins() {
|
||||
Map<String, Object> data = service.itemsData(view, null, 1, 20, "monitor", noFilter());
|
||||
assertThat(data.get("total")).isEqualTo(3);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateShop> shops = (List<DuplicateShop>) data.get("shops");
|
||||
assertThat(shops.stream().map(DuplicateShop::shopName).toList())
|
||||
.containsExactly("ShopA", "ShopB", "ShopC", "ShopD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemFilterByAsinAndSite() {
|
||||
ShopDataDuplicateCheckQueryService.Filters filters =
|
||||
ShopDataDuplicateCheckQueryService.Filters.clean("A0000001", "", "", "UK", "", "");
|
||||
Map<String, Object> data = service.itemsData(view, null, 1, 20, "all", filters);
|
||||
assertThat(data.get("total")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailSortedByShopCountThenEarliestShelfDesc() {
|
||||
Map<String, Object> data = service.detailData(view, null, 1, 24, noFilter());
|
||||
assertThat(data.get("total")).isEqualTo(3);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||
assertThat(items).extracting(map -> map.get("asin")).containsExactly("E0000001", "A0000001", "F0000001");
|
||||
Map<String, Object> e = items.get(0);
|
||||
assertThat(e.get("first_date")).isEqualTo("2026-08-29 04:34:00");
|
||||
assertThat(e.get("brand")).isEqualTo("BrandE");
|
||||
assertThat(e.get("shop_names")).isEqualTo(List.of("ShopA", "ShopB", "ShopC"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DuplicateOccurrence> occurrences = (List<DuplicateOccurrence>) e.get("occurrences");
|
||||
assertThat(occurrences).extracting(DuplicateOccurrence::date)
|
||||
.containsExactly("2026年8月29日 上午4:34", "2026-08-30", "2026-08-31");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailLeaderCropRemovesSingleShopAsin() {
|
||||
Set<String> visible = Set.of("shopa", "shopb");
|
||||
Map<String, Object> data = service.detailData(view, visible, 1, 24, noFilter());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||
// E 的 ShopC 记录被裁掉后剩 2 店;A/E 同为 2 店,按"最早上架晚者在前" A 早于 E
|
||||
assertThat(items).extracting(map -> map.get("asin")).containsExactly("A0000001", "E0000001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingShapes() {
|
||||
Map<String, Object> overview = service.pendingOverview();
|
||||
assertThat(overview.get("pending")).isEqualTo(true);
|
||||
assertThat(overview.get("summary")).isEqualTo(Map.of());
|
||||
Map<String, Object> list = service.pendingList(1, 20);
|
||||
assertThat(list.get("total")).isEqualTo(0);
|
||||
assertThat(list.get("scanned_at")).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportCsvBomHeaderAndMonitorRows() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.writeExport(view, null, "monitor", noFilter(), out);
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertThat(bytes[0]).isEqualTo((byte) 0xEF);
|
||||
assertThat(bytes[1]).isEqualTo((byte) 0xBB);
|
||||
assertThat(bytes[2]).isEqualTo((byte) 0xBF);
|
||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||
String[] lines = text.split("\r\n");
|
||||
assertThat(lines[0]).endsWith("ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌");
|
||||
// 数据行 = E(3 记录) + A(2) + F(2) = 7,非空行总数为 8(尾随 CRLF 会产生一个空元素)
|
||||
long nonEmpty = java.util.Arrays.stream(lines).filter(line -> !line.isEmpty()).count();
|
||||
assertThat(nonEmpty).isEqualTo(8);
|
||||
assertThat(lines[1]).startsWith("E0000001,3,");
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.TestFixtures;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DuplicateCheckAggregatorTest {
|
||||
|
||||
@Test
|
||||
void aggregatesFixtureExactlyLikePython() {
|
||||
DuplicateCheckAggregator.Aggregate aggregate =
|
||||
DuplicateCheckAggregator.aggregate(TestFixtures.shopParsed(), "job");
|
||||
DuplicateScanSummary summary = aggregate.summary();
|
||||
assertThat(summary.shopCount()).isEqualTo(4);
|
||||
assertThat(summary.asinTotal()).isEqualTo(6);
|
||||
assertThat(summary.recordTotal()).isEqualTo(10);
|
||||
assertThat(summary.duplicateAsinTotal()).isEqualTo(3);
|
||||
assertThat(summary.duplicateShopCount()).isEqualTo(4);
|
||||
assertThat(summary.siteCount()).isEqualTo(3);
|
||||
assertThat(summary.asinPerShop()).isEqualTo(2.5);
|
||||
assertThat(summary.source()).isEqualTo("job");
|
||||
|
||||
List<DuplicateShop> shops = aggregate.payload().shops();
|
||||
assertThat(shops.stream().map(DuplicateShop::shopName).toList())
|
||||
.containsExactly("ShopA", "ShopB", "ShopC", "ShopD");
|
||||
assertThat(shops.stream().map(DuplicateShop::asinCount).toList())
|
||||
.containsExactly(3, 3, 2, 2);
|
||||
|
||||
List<DuplicateItem> items = aggregate.payload().items();
|
||||
// 扫描排序:店铺数倒序 → ASIN 升序
|
||||
assertThat(items.stream().map(DuplicateItem::asin).toList())
|
||||
.containsExactly("E0000001", "A0000001", "F0000001", "B0000001", "C0000001", "D0000001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void occurrenceCarriesSheetCountry() {
|
||||
DuplicateCheckAggregator.Aggregate aggregate =
|
||||
DuplicateCheckAggregator.aggregate(TestFixtures.shopParsed(), "job");
|
||||
DuplicateItem e = aggregate.payload().items().get(0);
|
||||
assertThat(e.shopCount()).isEqualTo(3);
|
||||
assertThat(e.recordCount()).isEqualTo(3);
|
||||
assertThat(e.occurrences().stream()
|
||||
.map(DuplicateOccurrence::country).toList())
|
||||
.containsExactlyInAnyOrder("UK", "UK", "FR");
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DuplicateCheckCsvWriterTest {
|
||||
|
||||
@Test
|
||||
void writesBomCrlfAndMinimalQuoting() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
DuplicateCheckCsvWriter.write(out, List.of(
|
||||
List.of("A", "B"),
|
||||
List.of("with,comma", "say \"hi\"", "two\nlines")));
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertThat(bytes[0]).isEqualTo((byte) 0xEF);
|
||||
assertThat(bytes[1]).isEqualTo((byte) 0xBB);
|
||||
assertThat(bytes[2]).isEqualTo((byte) 0xBF);
|
||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||
// BOM 为首个字符,其后紧跟表头
|
||||
assertThat((int) text.charAt(0)).isEqualTo(0xFEFF);
|
||||
assertThat(text.charAt(1)).isEqualTo('A');
|
||||
assertThat(text).contains("A,B\r\n");
|
||||
assertThat(text).contains("\"with,comma\"");
|
||||
assertThat(text).contains("\"say \"\"hi\"\"\"");
|
||||
assertThat(text).contains("\"two\nlines\"");
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DuplicateCheckTimeNormalizerTest {
|
||||
|
||||
@Test
|
||||
void normalizesPythonSortTimeCases() {
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026-08-30"))
|
||||
.isEqualTo("2026-08-30 00:00:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026-08-30 08:30:00"))
|
||||
.isEqualTo("2026-08-30 08:30:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026年8月29日 上午4:34"))
|
||||
.isEqualTo("2026-08-29 04:34:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026年8月29日 下午2:15"))
|
||||
.isEqualTo("2026-08-29 14:15:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026年8月29日 晚上8:00"))
|
||||
.isEqualTo("2026-08-29 20:00:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026年8月29日 凌晨12:05"))
|
||||
.isEqualTo("2026-08-29 00:05:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026/8/18 4:34"))
|
||||
.isEqualTo("2026-08-18 04:34:00");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("未知日期"))
|
||||
.isEqualTo("未知日期");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime(""))
|
||||
.isEqualTo("");
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime(null))
|
||||
.isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidCalendarDateFallsBackToOriginal() {
|
||||
assertThat(DuplicateCheckTimeNormalizer.normalizeSortTime("2026-13-01 04:34"))
|
||||
.isEqualTo("2026-13-01 04:34");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateKeyNormalizesCommonFormats() {
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("2026-08-30T08:30")).isEqualTo("2026-08-30");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("2026-08-30 08:30:00")).isEqualTo("2026-08-30");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("2026年8月19日 上午4:34")).isEqualTo("2026-08-19");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("2026.8.19")).isEqualTo("2026-08-19");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("2026/8/19")).isEqualTo("2026-08-19");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("abc")).isEqualTo("abc");
|
||||
assertThat(DuplicateCheckTimeNormalizer.dateKey("")).isEqualTo("");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user