feat(admin): A1 管理后台收敛 Java 单后台完整实现并修复 guard 误拦内部令牌
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:
2026-09-04 11:30:09 +08:00
parent 3420c72c1d
commit 8241cd704e
43 changed files with 3071 additions and 1318 deletions
@@ -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;
@@ -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);
}
@@ -16,4 +16,6 @@ public class AdminUserItemVo {
private String creatorUsername;
@JsonProperty("created_at")
private String createdAt;
@JsonProperty("pinyin_abbr")
private String pinyinAbbr;
}
@@ -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;
}
@@ -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, "无权访问用户管理模块");
}
}
}
@@ -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 '#';
}
}
}
@@ -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);
}
@@ -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));
}
}
@@ -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();
}
@@ -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);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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_countitems 保持缓存序。 */
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=truesummary 为空对象)。 */
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;
}
}
@@ -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_atyyyy-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;
}
}
@@ -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();
}
}
@@ -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;
/**
* 导出 CSVUTF-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('"');
}
}
@@ -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_time19 位排序时间,无法解析原样返回。 */
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_keyYYYY-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;
}
}
}
@@ -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();
}
}
@@ -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) {
}
@@ -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);
@@ -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);
@@ -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/summaryshops/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());
}
}
@@ -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,");
}
}
@@ -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");
}
}
@@ -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\"");
}
}
@@ -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("");
}
}
+1 -3
View File
@@ -12,7 +12,7 @@ from flask_cors import CORS
from utils.db import init_db
from blueprints.auth import auth
from blueprints.main import main
from blueprints.admin_api import admin_api, start_duplicate_scan_scheduler
from blueprints.admin_api import admin_api
from blueprints.version import version_bp
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -50,8 +50,6 @@ app.register_blueprint(version_bp)
def run_app(host='0.0.0.0', port=15124):
init_db()
# 每日凌晨全量扫描重复 ASIN,结果缓存 MySQL,页面读取缓存不再实时拉取 Excel
start_duplicate_scan_scheduler(app)
app.run(host=host, port=port, threaded=True, use_reloader=False)
+117 -740
View File
@@ -15,8 +15,6 @@ from pathlib import Path
from urllib.parse import quote
import requests
from openpyxl import load_workbook
from openpyxl.utils.exceptions import InvalidFileException
from requests.adapters import HTTPAdapter
from flask import (
Blueprint,
@@ -436,7 +434,7 @@ class _PermissionProxyError(Exception):
def _proxy_permission_java(
method, path, *, params=None, json_data=None, files=None, data=None, current_row=None):
method, path, *, params=None, json_data=None, files=None, data=None, current_row=None, timeout=10):
"""Call Java permission APIs using either forwarded JWT or trusted Flask identity."""
proxy_params = {}
request_row = getattr(g, '_current_user_row', None) if has_request_context() else None
@@ -458,6 +456,7 @@ def _proxy_permission_java(
files=files,
data=data,
headers=headers,
timeout=timeout,
)
return result, error_response, status
@@ -1826,477 +1825,6 @@ def _load_shop_data_crawl_download_rows(result_ids):
conn.close()
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES = 256 * 1024 * 1024
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60)
# 重复 ASIN 扫描缓存:结果存 MySQLshop_data_duplicate_scan 表),
# 定时任务每天凌晨执行一次全量扫描,页面读取最近一次结果,避免实时拉取 Excel 消耗资源。
_DUPLICATE_SCAN_TABLE = 'shop_data_duplicate_scan'
_duplicate_scan_lock = threading.Lock()
_duplicate_scan_running = False
def _ensure_duplicate_scan_table():
"""按需建表:重复 ASIN 扫描结果缓存表。"""
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
f"""
CREATE TABLE IF NOT EXISTS {_DUPLICATE_SCAN_TABLE} (
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
"""
)
conn.commit()
finally:
conn.close()
def _save_duplicate_scan(status, summary=None, payload=None, error_text=None):
"""保存一次扫描结果记录;status: SUCCESS / FAILED。"""
import json as _json
conn = get_db()
scan_id = None
try:
with conn.cursor() as cur:
cur.execute(
f'INSERT INTO {_DUPLICATE_SCAN_TABLE} (status, error_text, summary_json, payload_json, created_at, finished_at) '
'VALUES (%s, %s, %s, %s, NOW(), NOW())',
(
status,
error_text,
_json.dumps(summary or {}, ensure_ascii=False),
_json.dumps(payload or [], ensure_ascii=False),
),
)
scan_id = cur.lastrowid
conn.commit()
return scan_id
finally:
conn.close()
def _latest_duplicate_scan():
"""最近一次成功扫描记录:{'scanned_at': str, 'summary': dict, 'items': list, 'shops': list}|None。
兼容两种缓存格式:
- 新版(重复检查页):payload = {'shops': [...], 'items': [...]}
- 旧版(跨店重复明细):payload 直接为 items 列表,shops 由明细推导。
"""
import json as _json
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
f'SELECT id, summary_json, payload_json, created_at FROM {_DUPLICATE_SCAN_TABLE} '
"WHERE status = 'SUCCESS' ORDER BY id DESC LIMIT 1"
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return None
summary_raw = row.get('summary_json') or {}
payload = row.get('payload_json') or '[]'
# summary_json / payload_json 在 MySQL 中为 JSON 字符串,读取后需反序列化
summary = _json.loads(summary_raw) if isinstance(summary_raw, str) else (summary_raw or {})
summary = summary if isinstance(summary, dict) else {}
payload = _json.loads(payload) if isinstance(payload, str) else (payload or [])
if isinstance(payload, dict):
shops = payload.get('shops') or []
items = payload.get('items') or []
else:
# 旧格式缓存:兼容旧页面,店铺概览由明细推导
items = payload if isinstance(payload, list) else []
shop_seen = {}
for item in items:
for occ in (item.get('occurrences') or []):
shop_name = occ.get('shop_name') or ''
shop_seen[shop_name] = shop_seen.get(shop_name, 0) + 1
shops = [
{'shop_name': shop_name, 'asin_count': 0, 'group_name': ''}
for shop_name in sorted(shop_seen, key=lambda name: (-shop_seen[name], name))
]
return {
'scanned_at': str(row.get('created_at') or ''),
'summary': summary,
'items': items if isinstance(items, list) else [],
'shops': shops if isinstance(shops, list) else [],
}
def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
"""从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。"""
# row 支持 id(下载行)或 result_id(管理列集别名)
result_id = row.get('id') or row.get('result_id') or 0
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(result_id)}/download"
headers, params = _backend_java_internal_request()
response = _get_backend_java_session().get(
url,
params=params,
headers=headers,
stream=True,
timeout=timeout or _SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT,
)
try:
response.raise_for_status()
total = 0
chunks = []
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
total += len(chunk)
if total > _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES:
raise ValueError('结果文件过大,无法分析')
chunks.append(chunk)
raw = b''.join(chunks)
if not raw:
raise ValueError(f'结果文件为空 result_id={result_id}')
# Java 内部端点鉴权/归属失败时返回 JSON 错误体(HTTP 200),解析报错更直观
if raw.lstrip().startswith(b'{'):
try:
err_payload = json.loads(raw.decode('utf-8', 'replace'))
message = err_payload.get('message') or err_payload.get('error') or '未知错误'
except ValueError:
message = raw[:200].decode('utf-8', 'replace')
raise ValueError(f'结果文件下载失败 result_id={result_id}: {message}')
return raw
finally:
response.close()
def _shop_data_crawl_cell_text(cell):
"""读取单元格文本:日期/数字等统一转字符串,None 返回空串。"""
if cell is None:
return ''
value = cell.value
if value is None:
return ''
if isinstance(value, datetime):
return value.strftime('%Y-%m-%d')
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _shop_data_date_key(date_text):
"""把各种日期文本归一化为 YYYY-MM-DD,供范围比较。
支持:ISO2026-08-19)、中文(2026年8月19日 上午4:34)、
yyyy.m.d / yyyy/m/d 等常见格式;无法识别时返回原字符串。
"""
text = (date_text or '').strip()
if not text:
return ''
if len(text) >= 10 and text[4] == '-' and text[7] == '-':
return text[:10]
year = month = day = None
m = re.search(r'(\d{4})\s*[年./-]\s*(\d{1,2})\s*[月./-]\s*(\d{1,2})', text)
if m:
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
if year and month and day:
try:
return f'{year:04d}-{month:02d}-{day:02d}'
except ValueError:
return text
return text
_SHOP_DATA_SHEET_COUNTRY_MAP = {
'英国': 'UK', 'uk': 'UK', 'u.k.': 'UK', 'united kingdom': 'UK',
'德国': 'DE', 'de': 'DE', 'germany': 'DE',
'法国': 'FR', 'fr': 'FR', 'france': 'FR',
'意大利': 'IT', 'it': 'IT', 'italy': 'IT',
'西班牙': 'ES', 'es': 'ES', 'spain': 'ES',
}
def _shop_data_sheet_country(sheet_name):
"""结果文件 sheet 名 → 国家码(英国/德国/法国…);无法识别返回空串。"""
key = str(sheet_name or '').strip().lower()
return _SHOP_DATA_SHEET_COUNTRY_MAP.get(key, '')
def _shop_data_crawl_parse_workbook(workbook):
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。
每行附带所在 sheet 映射的国家码 country(记录级站点,供重复检查页按站点筛选/展示)。
"""
rows = []
for sheet in workbook.worksheets:
header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), []))
header = [_shop_data_crawl_cell_text(cell) for cell in header_cells]
try:
asin_col = header.index('ASIN')
except ValueError:
continue
date_col = header.index('日期') if '日期' in header else None
price_col = header.index('价格') if '价格' in header else None
brand_col = header.index('品牌') if '品牌' in header else None
sheet_country = _shop_data_sheet_country(sheet.title)
for sheet_row in sheet.iter_rows(min_row=2):
asin = _shop_data_crawl_cell_text(sheet_row[asin_col])
if not asin:
continue
rows.append({
'asin': asin,
'date': _shop_data_crawl_cell_text(sheet_row[date_col]) if date_col is not None else '',
'price': _shop_data_crawl_cell_text(sheet_row[price_col]) if price_col is not None else '',
'brand': _shop_data_crawl_cell_text(sheet_row[brand_col]) if brand_col is not None else '',
'country': sheet_country,
})
return rows
def _shops_with_latest_results():
"""查询每家店铺最新结果行(用于重复 ASIN 扫描)。返回 shop_items 列表。"""
conditions = [
"r.module_type = 'SHOP_DATA_CRAWL'",
"t.module_type = 'SHOP_DATA_CRAWL'",
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
]
where_sql = ' AND '.join(conditions)
conn = get_db()
try:
with conn.cursor() as cur:
shop_key_sql = "TRIM(COALESCE(r.source_filename, ''))"
grouped_from_sql = (
' FROM biz_file_result r '
'JOIN biz_file_task t ON t.id = r.task_id '
'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id '
'LEFT JOIN users u ON u.id = r.user_id '
'WHERE ' + where_sql
)
cur.execute(
'SELECT ' + shop_key_sql + ' AS shop_name, MAX('
+ _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at'
+ grouped_from_sql +
' GROUP BY ' + shop_key_sql +
' ORDER BY latest_created_at DESC, shop_name ASC',
)
group_rows = cur.fetchall()
group_names = _shop_data_crawl_group_names(cur, group_rows)
result_rows_by_shop = {}
selected_shop_names = [row.get('shop_name') for row in group_rows]
if selected_shop_names:
placeholders = ','.join(['%s'] * len(selected_shop_names))
cur.execute(
'SELECT ranked.* FROM (SELECT ' + _SHOP_DATA_CRAWL_ADMIN_COLUMNS +
', ROW_NUMBER() OVER (PARTITION BY ' + shop_key_sql +
' ORDER BY ' + _SHOP_DATA_CRAWL_LATEST_TIME_SQL
+ ' DESC, r.id DESC) AS shop_row_number '
' FROM biz_file_result r '
'JOIN biz_file_task t ON t.id = r.task_id '
'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id '
'LEFT JOIN users u ON u.id = r.user_id '
'WHERE ' + where_sql +
f' AND {shop_key_sql} IN ({placeholders})' +
') ranked WHERE ranked.shop_row_number <= 1 '
'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC',
tuple(selected_shop_names),
)
for row in cur.fetchall():
shop_key = _shop_data_crawl_shop_key(row.get('shop_name'))
result_rows_by_shop.setdefault(shop_key, []).append(row)
finally:
conn.close()
# 逐店读取结果文件并解析
shop_items = []
for rows in result_rows_by_shop.values():
for row in rows:
result_id = int(row.get('result_id') or 0)
if result_id <= 0:
continue
try:
raw = _shop_data_crawl_fetch_result_bytes(row)
try:
parsed = _shop_data_crawl_parse_workbook(
load_workbook(io.BytesIO(raw), read_only=True, data_only=True))
except (InvalidFileException, KeyError, ValueError, zipfile.BadZipFile) as exc:
current_app.logger.warning(
'[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc)
continue
shop_items.append({
'shop_name': row.get('shop_name') or '未命名',
'group_name': _shop_data_crawl_group_name(group_names, row.get('shop_name')),
'country_codes': _shop_data_crawl_country_codes_from_json(row.get('country_codes_json'))
or _shop_data_crawl_country_codes(row.get('request_json')),
'rows': parsed,
})
except (requests.RequestException, ValueError) as exc:
current_app.logger.warning(
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
return shop_items
def _build_duplicate_scan_cache(shop_items, source='job'):
"""由 shop_items 构建全量重复检查缓存:shops 概览 + 明细 items + 指标统计。
返回 {'shops': [...], 'items': [...], 'summary': {唯一ASIN/上架记录/重复ASIN…}}
items 内 occurrences 记录带 country(结果文件 sheet 映射的站点)。
"""
asin_occurrences = {}
shop_agg = {}
for shop_item in shop_items:
shop_name = shop_item['shop_name'] or '未命名'
group_name = shop_item['group_name'] or ''
country_codes = shop_item['country_codes'] or []
shop_agg[shop_name] = {'group_name': group_name, 'country_codes': country_codes}
for row in shop_item['rows']:
asin = row['asin'].strip().upper()
if not asin:
continue
asin_occurrences.setdefault(asin, []).append({
'asin': asin,
'date': row['date'],
'price': row['price'],
'brand': row['brand'],
'shop_name': shop_name,
'group_name': group_name,
'country_codes': country_codes,
'country': row.get('country') or '',
})
items = []
total_records = 0
for asin, occurrences in asin_occurrences.items():
shop_count = len({item['shop_name'] for item in occurrences})
total_records += len(occurrences)
items.append({
'asin': asin,
'shop_count': shop_count,
'record_count': len(occurrences),
'occurrences': occurrences,
})
items.sort(key=lambda item: (-item['shop_count'], item['asin']))
repeated = sum(1 for item in items if item['shop_count'] >= 2)
shops = []
for shop_name, agg in sorted(shop_agg.items()):
shop_asin_set = {
occ['asin']
for occ_list in (oo for oo in asin_occurrences.values())
for occ in occ_list
if occ['shop_name'] == shop_name
}
shops.append({
'shop_name': shop_name,
'group_name': agg['group_name'],
'country_codes': agg['country_codes'],
'asin_count': len(shop_asin_set),
'record_count': sum(1 for occ_list in asin_occurrences.values() for occ in occ_list if occ['shop_name'] == shop_name),
})
shops.sort(key=lambda shop: (-shop['asin_count'], shop['shop_name']))
summary = {
'shop_count': len(shops),
'asin_total': len(items),
'record_total': total_records,
'duplicate_asin_total': repeated,
'duplicate_shop_count': len({occ['shop_name'] for item in items if item['shop_count'] >= 2 for occ in item['occurrences']}),
'site_count': len({occ['country'] for occ_list in asin_occurrences.values() for occ in occ_list if occ.get('country')}),
'asin_per_shop': round(float(total_records) / len(shops), 1) if shops else 0.0,
'source': source,
}
return {'shops': shops, 'items': items, 'summary': summary}
def _build_duplicate_scan_items(shop_items):
"""由 shop_items 聚合跨店重复 ASIN 完整结果(不含筛选,返回全量列表)。
保持旧接口语义(仅跨店重复),供导入模拟重分析等旧逻辑复用;
新页面走 _build_duplicate_scan_cache 全量缓存。
"""
cache = _build_duplicate_scan_cache(shop_items)
return cache['items']
def _run_duplicate_scan_job():
"""执行一次全量重复检查扫描并落库(新格式:shops + items + summary)。返回 (ok, scanned_at, summary)。"""
global _duplicate_scan_running
if _duplicate_scan_running:
return False, '', '扫描进行中'
_duplicate_scan_running = True
try:
shop_items = _shops_with_latest_results()
cache = _build_duplicate_scan_cache(shop_items, source='job')
_save_duplicate_scan('SUCCESS', summary=cache['summary'], payload={
'shops': cache['shops'],
'items': cache['items'],
})
return True, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), cache['summary']
except Exception as exc:
current_app.logger.exception('[shop-data-crawl] 重复检查定时扫描失败: %s', exc)
try:
_save_duplicate_scan('FAILED', error_text=str(exc))
except Exception:
current_app.logger.exception('[shop-data-crawl] 重复检查扫描失败记录落库失败')
return False, '', '执行失败:%s' % exc
finally:
_duplicate_scan_running = False
def start_duplicate_scan_scheduler(app):
"""每天凌晨 03:10 执行一次重复 ASIN 全量扫描(后台守护线程)。"""
_ensure_duplicate_scan_table()
def _loop():
while True:
now = datetime.now()
# 下一个 03:10(含今天,若已过则明天)
next_run = now.replace(hour=3, minute=10, second=0, microsecond=0)
if next_run <= now:
next_run = next_run.replace(day=next_run.day) + timedelta(days=1)
delay = (next_run - now).total_seconds()
app.logger.info('[shop-data-crawl] 重复ASIN定时扫描将于 %s 执行(%d 秒后)', next_run, int(delay))
time.sleep(delay)
try:
with app.app_context():
ok, scanned_at, summary = _run_duplicate_scan_job()
app.logger.info('[shop-data-crawl] 重复ASIN定时扫描结束 ok=%s summary=%s', ok, summary)
except Exception as exc:
app.logger.exception('[shop-data-crawl] 重复ASIN定时扫描异常: %s', exc)
threading.Thread(target=_loop, daemon=True, name='duplicate-scan-scheduler').start()
def _duplicate_check_filter_cache(cache, role, current_row):
"""按角色数据范围裁剪重复检查缓存:超管返回全量,主管只返回自己组的店铺。
返回 (shops, items)shops 为可见店铺概览;items 为裁剪 occurrences 后的明细
(裁剪后同一 ASIN 只剩 1 家店时也会保留 —— 展示完整台账,是否重复由 shop_count>=2 标记)。
"""
visible_shops = _shop_data_managed_shop_names(role, current_row)
if visible_shops is None:
return cache.get('shops') or [], cache.get('items') or []
shops = []
for shop in (cache.get('shops') or []):
if _shop_data_crawl_shop_key(shop.get('shop_name')) in visible_shops:
shops.append(shop)
items = []
for item in (cache.get('items') or []):
occurrences = [
occ for occ in (item.get('occurrences') or [])
if _shop_data_crawl_shop_key(occ.get('shop_name')) in visible_shops
]
if occurrences:
items.append({
'asin': item.get('asin'),
'shop_count': len({occ.get('shop_name') for occ in occurrences}),
'record_count': len(occurrences),
'occurrences': occurrences,
})
return shops, items
@admin_api.route('/shop-data-crawl/duplicate-check-overview')
@login_required
def shop_data_crawl_duplicate_check_overview():
@@ -2307,58 +1835,17 @@ def shop_data_crawl_duplicate_check_overview():
if denied:
return denied
try:
force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes')
if force:
if not _duplicate_scan_lock.acquire(blocking=False):
return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409
try:
ok, scanned_at, summary = _run_duplicate_scan_job()
if not ok:
return jsonify({'success': False, 'error': summary}), 500
cache = _latest_duplicate_scan()
finally:
_duplicate_scan_lock.release()
else:
cache = _latest_duplicate_scan()
if not cache:
return jsonify({
'success': True, 'pending': True,
'scanned_at': '', 'source': '',
'summary': {}, 'shops': [],
})
shops, items = _duplicate_check_filter_cache(cache, role, current_row)
asin_total = len(items)
record_total = sum(item.get('record_count') or 0 for item in items)
repeated = [item for item in items if item['shop_count'] >= 2]
shop_asin_totals = {}
duplicate_shops = set()
site_codes = set()
for item in items:
occs = item.get('occurrences') or []
for occ in occs:
shop_asin_totals[occ.get('shop_name')] = shop_asin_totals.get(occ.get('shop_name'), set())
shop_asin_totals[occ.get('shop_name')].add(item['asin'])
if item['shop_count'] >= 2:
duplicate_shops.add(occ.get('shop_name'))
if occ.get('country'):
site_codes.add(occ['country'])
summary = {
'shop_count': len(shops),
'asin_total': asin_total,
'record_total': record_total,
'duplicate_asin_total': len(repeated),
'duplicate_shop_count': len(duplicate_shops),
'site_count': len(site_codes),
'asin_per_shop': round(float(record_total) / len(shops), 1) if shops else 0.0,
'source': (cache.get('summary') or {}).get('source', ''),
}
return jsonify({
'success': True,
'pending': False,
'scanned_at': cache['scanned_at'],
'summary': summary,
'shops': shops,
})
force_raw = (request.args.get('force') or '').strip()
params = {}
if force_raw in ('1', 'true', 'yes'):
params['force'] = '1'
result, error_response, status = _proxy_permission_java(
'GET', '/api/admin/shop-data-crawl/duplicate-check-overview',
params=params, timeout=(10, 1800))
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, **payload})
except Exception as exc:
return _internal_error(exc)
@@ -2373,78 +1860,68 @@ def shop_data_crawl_duplicate_check_items():
if denied:
return denied
try:
role, current_row = get_current_admin_role()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
view = (request.args.get('view') or 'monitor').strip().lower()
asin_filter = (request.args.get('asin') or '').strip().upper()
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
site_filter = (request.args.get('site') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
cache = _latest_duplicate_scan()
if not cache:
return jsonify({
'success': True, 'pending': True,
'items': [], 'shops': [],
'total': 0, 'page': page, 'page_size': page_size,
'scanned_at': '',
})
shops, all_items = _duplicate_check_filter_cache(cache, role, current_row)
has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to)
matched = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if view == 'monitor' and item['shop_count'] < 2:
continue
if not has_filter:
matched.append(item)
continue
hit = False
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
hit = True
break
if hit:
matched.append(item)
total = len(matched)
offset = (page - 1) * page_size
return jsonify({
'success': True,
'pending': False,
'items': matched[offset:offset + page_size],
'shops': shops,
'total': total,
'page': page,
'page_size': page_size,
'scanned_at': cache['scanned_at'],
})
params = {'page': str(page), 'pageSize': str(page_size), 'view': view}
shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
for key, value in (
('asin', (request.args.get('asin') or '').strip()),
('shopName', shop_name),
('country', (request.args.get('country') or '').strip()),
('site', (request.args.get('site') or '').strip()),
('dateFrom', (request.args.get('date_from') or '').strip()),
('dateTo', (request.args.get('date_to') or '').strip())):
if value:
params[key] = value
result, error_response, status = _proxy_permission_java(
'GET', '/api/admin/shop-data-crawl/duplicate-check-items', params=params)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, **payload})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return _internal_error(exc)
def _duplicate_check_effective_site(country, country_codes):
"""导出用站点:优先记录级 country,其次店铺 country_codes 汇总。"""
if country:
return country.upper()
codes = [c.upper() for c in (country_codes or []) if c]
return ''.join(codes) if codes else ''
@admin_api.route('/shop-data-crawl/duplicate-check-detail')
@login_required
def shop_data_crawl_duplicate_check_detail():
"""撞款详情(跨店重复 ASIN 卡片区):分页返回 shop_count>=2 的 ASIN 明细。
筛选条件与矩阵接口一致(遵循当前筛选),排序:店铺数倒序 → 最早上架时间倒序 → ASIN 升序。
"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(24, max(1, int(request.args.get('page_size', 6))))
params = {'page': str(page), 'pageSize': str(page_size)}
shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
for key, value in (
('asin', (request.args.get('asin') or '').strip()),
('shopName', shop_name),
('country', (request.args.get('country') or '').strip()),
('site', (request.args.get('site') or '').strip()),
('dateFrom', (request.args.get('date_from') or '').strip()),
('dateTo', (request.args.get('date_to') or '').strip())):
if value:
params[key] = value
result, error_response, status = _proxy_permission_java(
'GET', '/api/admin/shop-data-crawl/duplicate-check-detail', params=params)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, **payload})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return _internal_error(exc)
@admin_api.route('/shop-data-crawl/duplicate-check-export')
@@ -2459,161 +1936,61 @@ def shop_data_crawl_duplicate_check_export():
try:
role, current_row = get_current_admin_role()
view = (request.args.get('view') or 'monitor').strip().lower()
asin_filter = (request.args.get('asin') or '').strip().upper()
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
site_filter = (request.args.get('site') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
cache = _latest_duplicate_scan()
if not cache:
return jsonify({'success': False, 'error': '暂无扫描结果,请先点击「重新分析」'}), 400
shops, all_items = _duplicate_check_filter_cache(cache, role, current_row)
has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to)
# 展平为逐行记录(每行=一条上架记录),筛选逻辑与矩阵接口一致
flat = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if view == 'monitor' and item['shop_count'] < 2:
continue
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
flat.append({
'asin': item['asin'],
'shop_count': item['shop_count'],
'shop_name': occ.get('shop_name') or '',
'group_name': occ.get('group_name') or '',
'country': _duplicate_check_effective_site(occ.get('country'), occ.get('country_codes')),
'date': occ.get('date') or '',
'price': occ.get('price') or '',
'brand': occ.get('brand') or '',
})
flat.sort(key=lambda r: (-r['shop_count'], r['asin'], r['shop_name'], r['date']))
import csv
stream = io.StringIO()
writer = csv.writer(stream)
writer.writerow(['ASIN', '店铺数', '店铺', '分组', '站点', '上架时间', '价格', '品牌'])
for row in flat:
writer.writerow([row['asin'], row['shop_count'], row['shop_name'], row['group_name'],
row['country'], row['date'], row['price'], row['brand']])
payload = ('' + stream.getvalue()).encode('utf-8')
response = Response(payload, mimetype='text/csv; charset=utf-8')
response.headers['Content-Disposition'] = 'attachment; filename="shop-data-duplicate-check.csv"'
response.headers['Cache-Control'] = 'no-store'
return response
except Exception as exc:
return _internal_error(exc)
@admin_api.route('/shop-data-crawl/duplicate-asins')
@login_required
def shop_data_crawl_duplicate_asins():
"""按当前筛选条件展示跨店铺重复的 ASIN 明细。
默认读取最近一次成功扫描的缓存结果(定时任务每天凌晨全量扫描),
页面点「重新分析」携带 force=1 触发一次实时扫描(锁防并发,重复触发返回 409)。
"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
operator_id = (current_row or {}).get('id')
if not operator_id and has_request_context():
operator_id = session.get('user_id')
params = {'view': view}
if operator_id:
params['operatorId'] = operator_id
shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
for key, value in (
('asin', (request.args.get('asin') or '').strip()),
('shopName', shop_name),
('country', (request.args.get('country') or '').strip()),
('site', (request.args.get('site') or '').strip()),
('dateFrom', (request.args.get('date_from') or '').strip()),
('dateTo', (request.args.get('date_to') or '').strip())):
if value:
params[key] = value
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/duplicate-check-export"
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes')
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
asin_filter = (request.args.get('asin') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
if force:
# 实时扫描:持锁执行,避免与定时任务/其他请求并发
if not _duplicate_scan_lock.acquire(blocking=False):
return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409
resp = _get_backend_java_session().get(
url,
params=params,
headers={'X-Internal-Token': _resolve_internal_token()},
stream=True,
timeout=(10, 1800),
)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
ok, scanned_at, summary = _run_duplicate_scan_job()
if not ok:
return jsonify({'success': False, 'error': summary}), 500
cache = _latest_duplicate_scan()
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
resp.close()
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
def generate():
try:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
if chunk:
yield chunk
finally:
_duplicate_scan_lock.release()
else:
cache = _latest_duplicate_scan()
resp.close()
if not cache:
return jsonify({
'success': True,
'items': [],
'total': 0,
'page': page,
'page_size': page_size,
'analyzed_shop_count': 0,
'analyzed_result_count': 0,
'scanned_at': '',
'pending': True,
})
all_items = cache['items'] or []
# 旧接口只展示跨店重复:由全量缓存裁剪出 shop_count>=2 的 ASIN
all_items = [item for item in all_items if (item.get('shop_count') or 0) >= 2]
has_filter = bool(asin_filter or shop_name_filter or country_filter or date_from or date_to)
matched = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if not has_filter:
matched.append(item)
continue
# 有筛选时:任一 occurrence 命中即保留该 ASIN(展示完整记录)
hit = False
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
hit = True
break
if hit:
matched.append(item)
total_details = len(matched)
offset = (page - 1) * page_size
paged_details = matched[offset:offset + page_size]
return jsonify({
'success': True,
'items': paged_details,
'total': total_details,
'page': page,
'page_size': page_size,
'analyzed_shop_count': cache['summary'].get('shop_count', 0) if cache.get('summary') else 0,
'analyzed_result_count': len(all_items),
'scanned_at': cache['scanned_at'],
'source': cache.get('summary', {}).get('source', ''),
})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
return Response(
stream_with_context(generate()),
status=resp.status_code,
headers=headers,
content_type=resp.headers.get('Content-Type', 'text/csv; charset=utf-8'),
)
except Exception as exc:
return _internal_error(exc)
+154 -68
View File
@@ -1929,6 +1929,19 @@
var shopDataDuplicateScannedAt = '';
var shopDataDuplicateOverviewPending = false;
var shopDataDuplicateExporting = false;
// 撞款详情卡片区(跨店重复 ASIN)独立分页
var shopDataDuplicateDetailPage = 1;
var shopDataDuplicateDetailPageSize = 6;
var shopDataDuplicateDetailItems = [];
var shopDataDuplicateDetailTotal = 0;
var shopDataDuplicateDetailLoading = false;
// 站点代码 → 中文国家名(撞款详情/抽屉展示用),未识别回退原值
var DUP_SITE_LABELS = { DE: '德国', UK: '英国', FR: '法国', IT: '意大利', ES: '西班牙' };
function dupSiteLabel(site) {
var key = String(site || '').trim().toUpperCase();
return DUP_SITE_LABELS[key] || String(site || '').trim();
}
function buildShopDataDuplicateQuery(page) {
var params = new URLSearchParams();
@@ -2007,14 +2020,21 @@
}
var max = 0;
shops.forEach(function (shop) { if (shop.asin_count > max) max = shop.asin_count; });
// 新样式:双列网格,每格 = 店名 + 记录数 / 进度条 / N 个 ASIN(按 ASIN 数降序)
barsEl.innerHTML = shops.map(function (shop) {
var width = max ? Math.max(3, Math.round(shop.asin_count / max * 100)) : 0;
return '<div class="dup-chart-row">' +
'<span class="dup-chart-name" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</span>' +
'<span class="dup-chart-track"><span class="dup-chart-fill" style="width:' + width + '%"></span></span>' +
'<span class="dup-chart-num">' + shop.asin_count + '</span></div>';
var width = max ? Math.max(2, Math.round(shop.asin_count / max * 100)) : 0;
var recordCount = Number(shop.record_count || 0);
var asinCount = Number(shop.asin_count || 0);
return '<div class="dup-dist-item">' +
'<div class="dup-dist-item-head">' +
'<span class="dup-dist-name" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</span>' +
'<span class="dup-dist-record">' + recordCount.toLocaleString() + '</span>' +
'</div>' +
'<div class="dup-dist-track"><span class="dup-dist-fill" style="width:' + width + '%"></span></div>' +
'<div class="dup-dist-asin">' + asinCount.toLocaleString() + ' 个 ASIN</div>' +
'</div>';
}).join('');
noteEl.textContent = '共 ' + shops.length + ' 家店铺 · 按 ASIN 数量排序';
noteEl.textContent = '各店铺在本批站点中的上架记录量 · 共 ' + shops.length + ' 家店铺';
}
function shopDataDuplicateCellCount(item, shopName) {
@@ -2070,24 +2090,25 @@
if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail);
}
// 撞款详情卡片区:同一 ASIN 在多条店铺的上架明细(仅跨店铺重复,按店铺数倒序
// 站点徽章:中文国家(未识别回退原值
function dupSiteBadge(site) {
return '<span class="dup-site-badge dup-site-' + escapeHtml(String(site || '-').toUpperCase()) + '">'
+ escapeHtml(dupSiteLabel(site)) + '</span>';
}
// 撞款详情卡片区:同一 ASIN 在多条店铺的上架明细(独立分页,仅跨店铺重复)
function renderShopDataDuplicateDetailCards() {
var block = document.getElementById('dupCheckDetailBlock');
var cardsEl = document.getElementById('dupCheckDetailCards');
if (!block || !cardsEl) return;
var repeated = (shopDataDuplicateItems || []).filter(function (item) {
return Number(item.shop_count) >= 2;
}).slice().sort(function (a, b) {
return (Number(b.shop_count) - Number(a.shop_count)) || dupTimeRank(b.first_date || '') - dupTimeRank(a.first_date || '');
});
if (!repeated.length) {
if (!shopDataDuplicateDetailItems.length) {
block.style.display = 'none';
cardsEl.innerHTML = '';
return;
}
block.style.display = '';
cardsEl.innerHTML = repeated.map(function (item) {
// 按店铺分组:名称 + 次数 + 站点 + 上架时间(倒序)
cardsEl.innerHTML = shopDataDuplicateDetailItems.map(function (item) {
// 按店铺分组:名称 + 次数 + 国家 + 上架时间(倒序)
var shopsMap = {};
(item.occurrences || []).forEach(function (occ) {
var key = occ.shop_name || '-';
@@ -2103,7 +2124,7 @@
info.times.sort(function (a, b) { return (a < b ? 1 : (a > b ? -1 : 0)); });
info.times = Array.from(new Set(info.times));
var rowSites = Object.keys(info.sites).sort().map(function (site) {
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
return dupSiteBadge(site);
}).join('');
return '<tr>' +
'<td class="dup-shop">' + escapeHtml(info.shop) + '<span class="dup-detail-count">' + info.count + ' 次</span></td>' +
@@ -2119,70 +2140,130 @@
'<button class="btn btn-sm btn-secondary dup-detail-view-btn" type="button" data-open-asin-detail="' + escapeHtml(item.asin) + '">查看明细</button>' +
'</div>' +
'<table class="duplicate-asin-table dup-card-table"><thead>' +
'<tr><th>店铺</th><th>站点</th><th>上架时间</th></tr></thead>' +
'<tr><th>店铺</th><th>国家</th><th>上架时间</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table>' +
'</div>';
}).join('');
}
// 撞款详情分页加载:筛选/重置时回到第 1 页,矩阵翻页不影响详情页
function loadShopDataDuplicateDetail(page) {
if (shopDataDuplicateDetailLoading) return;
shopDataDuplicateDetailLoading = true;
var block = document.getElementById('dupCheckDetailBlock');
var cardsEl = document.getElementById('dupCheckDetailCards');
var params = new URLSearchParams();
params.set('page', String(page || 1));
params.set('page_size', String(shopDataDuplicateDetailPageSize));
var values = {
asin: document.getElementById('shopDataDupFilterAsin').value.trim(),
shop_name: document.getElementById('shopDataDupFilterShop').value.trim(),
country: document.getElementById('shopDataDupFilterCountry').value.trim(),
site: document.getElementById('shopDataDupFilterSite').value.trim(),
date_from: document.getElementById('shopDataDupFilterDateFrom').value,
date_to: document.getElementById('shopDataDupFilterDateTo').value
};
Object.keys(values).forEach(function (key) {
if (values[key]) params.set(key, values[key]);
});
cardsEl.innerHTML = '<div class="shop-data-empty-hint">加载中...</div>';
fetch('/api/admin/shop-data-crawl/duplicate-check-detail?' + params.toString())
.then(function (response) { return response.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '加载失败');
shopDataDuplicateDetailItems = res.items || [];
shopDataDuplicateDetailTotal = Number(res.total) || 0;
shopDataDuplicateDetailPage = res.page || 1;
renderShopDataDuplicateDetailCards();
if (block.style.display !== 'none') {
renderPagination('dupCheckDetailPagination', shopDataDuplicateDetailTotal,
shopDataDuplicateDetailPage, shopDataDuplicateDetailPageSize,
loadShopDataDuplicateDetail);
}
})
.catch(function (error) {
shopDataDuplicateDetailItems = [];
shopDataDuplicateDetailTotal = 0;
renderShopDataDuplicateDetailCards();
})
.finally(function () {
shopDataDuplicateDetailLoading = false;
});
}
// 抽屉徽章:基础滴状标签(按上下文附加不同配色类)
function dupDrawerBadge(text, cls) {
return '<span class="dup-drawer-badge' + (cls ? ' ' + cls : '') + '">' + escapeHtml(text) + '</span>';
}
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间升序明细 + 次数列
function openShopDataDuplicateDrawer(asin) {
var item = null;
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
if (!item) {
(shopDataDuplicateItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
}
if (!item) return;
var occurrences = item.occurrences || [];
var brand = '', dateMin = '', dateMax = '', sites = {}, prices = [];
var shopsMap = {};
occurrences.forEach(function (occ) {
if (!brand && occ.brand) brand = occ.brand;
var d = normalizeDuplicateTime(occ.date);
if (d) {
if (!dateMin || d < dateMin) dateMin = d;
if (!dateMax || d > dateMax) dateMax = d;
}
if (occ.country) sites[occ.country] = true;
if (occ.price && prices.indexOf(occ.price) < 0) prices.push(occ.price);
var shopKey = occ.shop_name || '-';
if (!shopsMap[shopKey]) {
shopsMap[shopKey] = { shop: occ.shop_name || '-', group: occ.group_name || '', sites: {}, times: {}, price: '', count: 0 };
}
var info = shopsMap[shopKey];
info.count++;
if (occ.country) info.sites[occ.country] = true;
if (d) info.times[d] = true;
if (occ.price && !info.price) info.price = occ.price;
var occurrences = (item.occurrences || []).slice();
var brand = item.brand || '';
// 上架时间归一化并升序排列(按时间正序展示)
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
occurrences.sort(function (a, b) {
var ta = a._dupTime || '', tb = b._dupTime || '';
if (ta === tb) return 0;
return ta < tb ? -1 : 1;
});
var dateRange = dateMin && dateMax ? (dateMin === dateMax ? dateMin : dateMin + ' ~ ' + dateMax) : '-';
var priceRange = prices.length ? prices.slice(0, 4).join(' / ') + (prices.length > 4 ? ' 等' : '') : '-';
var siteBadges = Object.keys(sites).sort().map(function (site) {
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
}).join('');
var rows = Object.keys(shopsMap).sort().map(function (key) {
var info = shopsMap[key];
var times = Object.keys(info.times).sort().reverse();
var rowSites = Object.keys(info.sites).sort().map(function (site) {
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
}).join('');
var sites = {}, firstDate = '';
occurrences.forEach(function (occ) {
if (occ.country) sites[occ.country] = true;
if (!brand && occ.brand) brand = occ.brand;
var t = occ._dupTime;
if (t && (!firstDate || t < firstDate)) firstDate = t;
});
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
var countsMap = {};
occurrences.forEach(function (occ) {
var shopKey = occ.shop_name || '-';
var siteKey = (occ.country || '').trim().toUpperCase() || '-';
var key = shopKey + '|||' + siteKey;
countsMap[key] = (countsMap[key] || 0) + 1;
});
var rows = occurrences.map(function (occ) {
var shopKey = occ.shop_name || '-';
var siteKey = (occ.country || '').trim().toUpperCase() || '-';
var times = [];
if (occ._dupTime && occ._dupTime.length >= 16) times.push(occ._dupTime.slice(0, 16));
else if (normalizeDuplicateTime(occ.date)) times.push(normalizeDuplicateTime(occ.date));
else occ.date || '';
var count = countsMap[shopKey + '|||' + siteKey] || 0;
var siteBadge = siteKey === '-' ? '<span class="dup-site-badge dup-site-NA">-</span>' : dupSiteBadge(siteKey);
return '<tr>' +
'<td><span class="dup-asin-cell">' + escapeHtml(info.shop) + '</span></td>' +
'<td>' + escapeHtml(info.group || '-') + '</td>' +
'<td>' + (rowSites || '-') + '</td>' +
'<td class="dup-date">' + times.map(escapeHtml).join('、') + '</td>' +
'<td class="dup-date">' + escapeHtml(info.price || '-') + '</td>' +
'<td>' + info.count + '</td></tr>';
'<td><span class="dup-asin-cell">' + escapeHtml(shopKey) + '</span></td>' +
'<td>' + siteBadge + '</td>' +
'<td class="dup-date">' + escapeHtml(times.join('')) + '</td>' +
'<td>' + count + '</td></tr>';
}).join('');
// 顶部「N 店在售」红色徽章
var shopCount = Number(item.shop_count || 0);
var recordCount = Number(item.record_count || 0);
var headerBadges =
dupDrawerBadge(shopCount + ' 店在售', 'dup-drawer-badge-danger') +
dupDrawerBadge(shopCount + ' 家店铺', 'dup-drawer-badge-blue') +
Object.keys(sites).sort().map(function (site) {
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
}).join('') +
dupDrawerBadge(recordCount + ' 次上架', 'dup-drawer-badge-green') +
(firstDate ? dupDrawerBadge('最早上架 ' + firstDate.slice(0, 16), 'dup-drawer-badge-cyan') : '');
document.getElementById('dupCheckDrawerAsin').textContent = item.asin;
document.getElementById('dupCheckDrawerSubtitle').textContent =
item.shop_count + ' 家店铺 · ' + item.record_count + ' 条上架记录';
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
subtitleEl.className = 'dup-drawer-subtitle dup-drawer-inline-badges';
subtitleEl.innerHTML = headerBadges;
document.getElementById('dupCheckDrawerBody').innerHTML =
'<dl class="dup-check-drawer-meta">' +
'<dt>品牌</dt><dd>' + escapeHtml(brand || '-') + '</dd>' +
'<dt>价格</dt><dd>' + escapeHtml(priceRange) + '</dd>' +
'<dt>日期范围</dt><dd>' + escapeHtml(dateRange) + '</dd>' +
'<dt>站点</dt><dd>' + (siteBadges || '-') + '</dd></dl>' +
'<h4 style="margin:14px 0 6px;font-size:13.5px;">店铺 / 站点 / 上架时间 / 次数</h4>' +
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
'<thead><tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间</th><th>价格</th><th>次数</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>';
'<thead><tr><th>店铺</th><th>国家</th><th>上架时间(按时间升序)</th><th>次数</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="4" class="shop-data-empty-hint">暂无明细</td></tr>') + '</tbody></table></div>';
document.getElementById('dupCheckDrawerMask').classList.add('show');
}
@@ -2246,7 +2327,7 @@
+ (shopDataDuplicateScannedAt ? ' · 扫描时间 ' + shopDataDuplicateScannedAt
: (shopDataDuplicateOverviewPending ? ' · 尚无扫描结果(点击「重新分析」立即扫描)' : ''));
renderShopDataDuplicateMatrix();
renderShopDataDuplicateDetailCards();
loadShopDataDuplicateDetail(shopDataDuplicateDetailPage || 1);
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal,
shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateCheckItems);
})
@@ -2255,7 +2336,7 @@
shopDataDuplicateTotal = 0;
list.innerHTML = '<div class="shop-data-empty-hint">加载失败:' + escapeHtml(error.message || '') + '</div>';
document.getElementById('shopDataDuplicateTotal').textContent = '';
renderShopDataDuplicateDetailCards();
loadShopDataDuplicateDetail(1);
})
.finally(function () {
shopDataDuplicateLoading = false;
@@ -2274,6 +2355,7 @@
monitorTab.setAttribute('aria-selected', monitorActive ? 'true' : 'false');
allTab.classList.toggle('active', !monitorActive);
allTab.setAttribute('aria-selected', !monitorActive ? 'true' : 'false');
shopDataDuplicateDetailPage = 1;
loadShopDataDuplicateCheckItems(1);
}
@@ -2503,10 +2585,14 @@
loadShopDataCrawlTasks(1);
};
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckOverview(true); };
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckItems(1); };
document.getElementById('btnFilterShopDataDuplicates').onclick = function () {
shopDataDuplicateDetailPage = 1;
loadShopDataDuplicateCheckItems(1);
};
document.getElementById('btnResetShopDataDuplicates').onclick = function () {
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterSite', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
.forEach(function (id) { document.getElementById(id).value = ''; });
shopDataDuplicateDetailPage = 1;
loadShopDataDuplicateCheckItems(1);
};
document.getElementById('dupCheckTabMonitor').onclick = function () { switchDupCheckView('monitor'); };
@@ -1,307 +0,0 @@
"""重复 ASIN 分析接口单元测试:模拟数据库行与结果文件,验证跨店铺重复聚合逻辑。"""
import sys
import unittest
from datetime import datetime
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from openpyxl import Workbook
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
def _make_workbook(rows_by_sheet):
"""构造结果 Workbookrows_by_sheet = {sheet名: [(日期, ASIN, 价格, 品牌), ...]}"""
wb = Workbook()
wb.remove(wb.active)
for sheet_name, rows in rows_by_sheet.items():
ws = wb.create_sheet(sheet_name)
ws.append(['日期', 'ASIN', '商品图片', '库存销量', '销售排名',
'页面浏览量', '售出件数', '价格', '推荐报价', '品牌'])
for date, asin, price, brand in rows:
row = [date, asin, '', '', '', '', '', price, '', brand]
ws.append(row)
return wb
class ShopDataDuplicateAsinTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.config['SECRET_KEY'] = 'test-secret'
self.group_rows = [
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 1)},
{'shop_name': 'Shop B', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 2)},
{'shop_name': 'Shop C', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 3)},
]
# 每家店一个结果文件:Shop A 与 Shop B 共享 ASIN1Shop C 单独 ASIN3
# 国家码按店铺实际 sheetA=UK+DE、B=仅UK、C=FR
self.shop_country = {
'Shop A': '["UK","DE"]',
'Shop B': '["UK"]',
'Shop C': '["FR"]',
}
self.shop_files = {
'Shop A': _make_workbook({
'英国': [('2026-08-30', 'B0ABC111', 'GBP 9.99', 'BrandA'),
('2026-08-30', 'B0UNIQUE1', 'GBP 5.00', 'BrandA')],
'德国': [('2026-08-30', 'B0ABC111', 'EUR 10.99', 'BrandA')],
}),
'Shop B': _make_workbook({
'英国': [('2026-08-31', 'B0ABC111', 'GBP 8.50', 'BrandA'),
('2026-08-31', 'B0ABC222', 'GBP 12.00', 'BrandB')],
}),
'Shop C': _make_workbook({
'法国': [('2026-08-29', 'B0ABC333', 'EUR 7.50', 'BrandC')],
}),
}
def _result_row(self, result_id, shop_name, country_codes_json=None):
return {
'result_id': result_id,
'task_id': result_id + 100,
'user_id': 7,
'shop_name': shop_name,
'shop_id': shop_name.lower(),
'task_no': f'task-{result_id}',
'task_status': 'SUCCESS',
'result_success': 1,
'result_error': None,
'task_error': None,
'file_error': None,
'result_file_url': f'object-{result_id}',
'result_filename': f'result-{result_id}.xlsx',
'result_file_size': 10,
'row_count': 2,
'request_json': '{}',
'country_codes_json': country_codes_json,
'created_at': '2026-08-31T01:15:00',
'updated_at': '2026-08-31T01:15:00',
'finished_at': '2026-08-31T01:15:00',
'latest_file_updated_at': '2026-08-31T01:15:00',
'file_job_id': None,
'file_status': 'SUCCESS',
'username': 'operator',
}
class _FakeCursor:
def __init__(self, group_rows, result_rows):
self.group_rows = group_rows
self.result_rows = result_rows
self.kind = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, sql, params=()):
if 'COUNT(*) AS total' in sql:
self.kind = 'count'
elif 'AS latest_created_at' in sql and 'GROUP BY' in sql:
self.kind = 'groups'
elif 'GROUP_CONCAT' in sql:
self.kind = 'group_names'
else:
self.kind = 'results'
def fetchone(self):
return {'total': len(self.group_rows)}
def fetchall(self):
if self.kind == 'groups':
return self.group_rows
if self.kind == 'group_names':
return [{'shop_name': row['shop_name'], 'group_name': 'Group-' + row['shop_name']}
for row in self.group_rows]
return self.result_rows
class _FakeConnection:
def __init__(self, cursor):
self.cursor_value = cursor
def cursor(self):
return self.cursor_value
def close(self):
pass
def _make_workbook_bytes(self, shop_name):
stream = BytesIO()
self.shop_files[shop_name].save(stream)
return stream.getvalue()
def _build_cache(self):
"""构造与旧扫描一致的缓存:3 家店铺、跨店重复 B0ABC111。"""
return {
'scanned_at': '2026-09-03 03:10:00',
'summary': {'shop_count': 3, 'total': 1},
'items': [{
'asin': 'B0ABC111',
'shop_count': 2,
'record_count': 3,
'occurrences': [
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['UK', 'DE']},
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['DE']},
{'asin': 'B0ABC111', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
'shop_name': 'Shop B', 'group_name': 'Group-Shop B', 'country_codes': ['UK']},
],
}],
}
def _run_request(self, query='', cache=None):
# 界面默认读缓存;force 走实时扫描(需 mock 扫描核心)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=cache), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)):
return admin_api.shop_data_crawl_duplicate_asins()
def test_detects_duplicate_asins_across_shops(self):
response = self._run_request('page=1&page_size=10', cache=self._build_cache())
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 1) # 只有 B0ABC111 跨店重复
self.assertEqual(body['analyzed_shop_count'], 3)
item = body['items'][0]
self.assertEqual(item['asin'], 'B0ABC111')
self.assertEqual(item['shop_count'], 2)
self.assertEqual(item['record_count'], 3) # ShopA 英国/德国 + ShopB 英国 共 3 条
shops = {occ['shop_name'] for occ in item['occurrences']}
self.assertEqual(shops, {'Shop A', 'Shop B'})
# 国家与日期从行/表头正确映射
shop_a = next(occ for occ in item['occurrences'] if occ['shop_name'] == 'Shop A')
self.assertIn('UK', shop_a['country_codes'])
self.assertEqual(shop_a['date'], '2026-08-30')
self.assertEqual(body['scanned_at'], '2026-09-03 03:10:00')
def test_no_cache_returns_pending_empty(self):
# 无缓存时(定时任务尚未执行):返回空 + pending 提示
response = self._run_request('page=1&page_size=10', cache=None)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 0)
self.assertEqual(body['items'], [])
self.assertTrue(body['pending'])
def test_cache_filters_match_live_semantics(self):
cache = self._build_cache()
# 店铺过滤 Shop B:命中 → 完整记录
response = self._run_request('page=1&page_size=10&shop_name=Shop+B', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
# 日期范围 08-31 命中
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 1)
# 国家 FR 无命中
response = self._run_request('page=1&page_size=10&country=FR', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 0)
def test_force_runs_live_scan(self):
# force=1 触发实时扫描并保存缓存后返回
cursor = self._FakeCursor(
self.group_rows,
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']])
for i, row in enumerate(self.group_rows)],
)
connection = self._FakeConnection(cursor)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
patch.object(admin_api, '_save_duplicate_scan', return_value=1), \
patch.object(admin_api, '_latest_duplicate_scan',
return_value=self._build_cache()), \
patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes',
side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])):
response = admin_api.shop_data_crawl_duplicate_asins()
self.assertIsNotNone(response)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
def test_force_conflict_when_lock_held(self):
# 锁被占(定时任务/他请求在扫)时 force 返回 409
cursor = self._FakeCursor(self.group_rows[:0], [])
connection = self._FakeConnection(cursor)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
patch.object(admin_api, '_duplicate_scan_lock'):
admin_api._duplicate_scan_lock.acquire.return_value = False
response = admin_api.shop_data_crawl_duplicate_asins()
# 409 返回 (jsonify, status) tuple
self.assertEqual(response[1], 409)
def test_latest_duplicate_scan_deserializes_json_columns(self):
"""真实读库:summary_json / payload_json 为 JSON 字符串,需反序列化为 dict/list。"""
class _ScanCursor(self._FakeCursor):
def __init__(self):
self.kind = None
def execute(self, sql, params=()):
self.kind = 'scan'
def fetchone(self):
return {
'id': 9,
'summary_json': '{"shop_count": 3, "total": 1, "source": "import"}',
'payload_json': '[{"asin": "B0ABC111", "shop_count": 2}]',
'created_at': datetime(2026, 9, 3, 13, 35, 25),
}
class _ScanConnection(self._FakeConnection):
def cursor(self):
return _ScanCursor()
with patch.object(admin_api, 'get_db', return_value=_ScanConnection(None)):
cache = admin_api._latest_duplicate_scan()
self.assertIsNotNone(cache)
self.assertEqual(cache['summary']['shop_count'], 3)
self.assertEqual(cache['summary']['source'], 'import')
self.assertEqual(cache['items'][0]['asin'], 'B0ABC111')
self.assertEqual(cache['scanned_at'], '2026-09-03 13:35:25')
def test_parse_workbook_skips_unknown_sheets(self):
wb = _make_workbook({'英国': [('2026-08-30', 'B0TEST01', 'GBP 1.00', '')]})
# 手工追加一个无标准表头的 sheet,模拟未知表
ws = wb.create_sheet('未知表')
ws.append(['随便', '某列'])
ws.append(['2026-08-30', 'B0NOHEADER'])
rows = admin_api._shop_data_crawl_parse_workbook(wb)
self.assertEqual([row['asin'] for row in rows], ['B0TEST01'])
def test_date_key_normalizes_chinese_datetime(self):
# 中文日期归一化为 ISO 供范围比较
cases = {
'2026年8月18日 上午4:34': '2026-08-18',
'2026年8月19日 05:48': '2026-08-19',
'2026-08-19': '2026-08-19',
'2026.8.19': '2026-08-19',
'2026/8/19': '2026-08-19',
'': '',
'abc': 'abc', # 无法识别时原样返回,范围比较自然失败
}
for raw, expected in cases.items():
self.assertEqual(admin_api._shop_data_date_key(raw), expected)
if __name__ == '__main__':
unittest.main()
@@ -1,4 +1,11 @@
"""店铺数据重复检查接口单元测试:全量缓存格式、矩阵分页、主管/超管数据范围过滤。"""
"""店铺数据重复检查接口(撞款 duplicate-check)转发契约测试。
四个端点已迁移到 Java/api/admin/shop-data-crawl/duplicate-check-{overview,items,detail,export}),
Flask 侧仅做:本地菜单/数据权限预检 → 带 operatorId + X-Internal-Token 转发 Java →
把 Java ApiResponse.data 原样透传并加 success 包装。本文件验证透传形状、参数名映射与错误码映射;
筛选/裁剪/排序/统计语义由 backend-java 模块的 Java 单测覆盖。
"""
import io
import sys
import unittest
from pathlib import Path
@@ -10,46 +17,50 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
def _shop(name, group, country_codes=None):
return {
'shop_name': name,
'group_name': group,
'country_codes': country_codes or [],
'rows': [],
}
def _java_ok(data):
"""模拟 Java ApiResponse 成功体:{'success': True, 'data': {...}, 'message': ...}。"""
return {'success': True, 'data': data, 'message': '操作成功'}, None, 200
class DuplicateCheckApiTest(unittest.TestCase):
"""直接对接口函数做单元测试:缓存通过 _latest_duplicate_scan mock 注入"""
def _java_fail_json(code, message):
"""构建转发失败态:error_response 为 Flask jsonify 对象(需在请求上下文内调用)"""
return ({'success': False, 'message': message, 'code': code},
admin_api.jsonify({'success': False, 'error': message}),
code)
class DuplicateCheckProxyTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.config['SECRET_KEY'] = 'test-secret'
# 4 家店 / 3 个组;ASIN 分布:
# A1: ShopA(UK) + ShopB(UK) —— 跨店重复
# B1: ShopA(DE) 唯一
# C1: ShopC(FR) 唯一
# D1: ShopD(UK) 唯一
self.cache = {
'scanned_at': '2026-09-04 03:10:00',
'summary': {
'shop_count': 4, 'asin_total': 4, 'record_total': 5,
'duplicate_asin_total': 1, 'duplicate_shop_count': 2,
'site_count': 3, 'asin_per_shop': 1.2, 'source': 'job',
},
'shops': [
{'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'asin_count': 2, 'record_count': 3},
{'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
{'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'asin_count': 1, 'record_count': 1},
{'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
],
'items': [
self.shops = [
{'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'asin_count': 3, 'record_count': 3},
{'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'asin_count': 3, 'record_count': 3},
{'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'asin_count': 2, 'record_count': 2},
{'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 2, 'record_count': 2},
]
self.items_all = [
{'asin': 'E0000001', 'shop_count': 3, 'record_count': 3, 'occurrences': [
{'asin': 'E0000001', 'date': '2026年8月29日 上午4:34', 'price': 'GBP 12.00', 'brand': 'BrandE',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'E0000001', 'date': '2026-08-30', 'price': 'GBP 12.00', 'brand': 'BrandE',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'E0000001', 'date': '2026-08-31', 'price': 'EUR 12.00', 'brand': 'BrandE',
'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'},
]},
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
{'asin': 'A0000001', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'country': 'UK'},
{'asin': 'A0000001', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
{'asin': 'A0000001', 'date': '2026-08-30 08:30:00', 'price': 'GBP 8.50', 'brand': 'BrandA',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
]},
{'asin': 'F0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
{'asin': 'F0000001', 'date': '2026-08-28', 'price': 'GBP 6.00', 'brand': 'BrandF',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'F0000001', 'date': '2026-08-30', 'price': 'GBP 6.00', 'brand': 'BrandF',
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
]},
{'asin': 'B0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'B0000001', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['DE'], 'country': 'DE'},
@@ -62,163 +73,253 @@ class DuplicateCheckApiTest(unittest.TestCase):
{'asin': 'D0000001', 'date': '2026-08-28', 'price': 'GBP 5.00', 'brand': 'BrandD',
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
]},
],
]
self.overview_all = {
'pending': False,
'scanned_at': '2026-09-04 03:10:00',
'summary': {
'shop_count': 4, 'asin_total': 6, 'record_total': 10,
'duplicate_asin_total': 3, 'duplicate_shop_count': 4,
'site_count': 3, 'asin_per_shop': 2.5, 'source': 'job',
},
'shops': self.shops,
}
def _access_patches(self, role='super_admin', current_row=None):
return [
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=(role, current_row or {'id': 1}, None)),
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=(role, current_row or {'id': 1}, None)),
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache),
]
def _patched(self, data):
return patch.object(admin_api, '_proxy_backend_java', side_effect=lambda *a, **k: _java_ok(data))
def _call(self, url, role='super_admin', current_row=None):
def _call(self, view_name, url, data, role='super_admin', current_row=None):
with self.app.test_request_context(url):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True):
for p in self._access_patches(role, current_row):
p.start()
try:
return admin_api.shop_data_crawl_duplicate_check_items()
finally:
for p in reversed(self._access_patches(role, current_row)):
p.stop()
def test_overview_super_admin_sees_all(self):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_overview()
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=(role, current_row or {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=(role, current_row or {'id': 1}, None)), \
self._patched(data):
return getattr(admin_api, view_name)()
def test_overview_super_admin_passthrough(self):
response = self._call('shop_data_crawl_duplicate_check_overview',
'/api/admin/shop-data-crawl/duplicate-check-overview',
self.overview_all)
body = response.get_json()
self.assertTrue(body['success'])
self.assertFalse(body['pending'])
self.assertEqual(body['summary']['asin_total'], 4)
self.assertEqual(body['summary']['record_total'], 5)
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
self.assertEqual(body['summary']['asin_total'], 6)
self.assertEqual(len(body['shops']), 4)
def test_items_matix_columns_are_shops(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor')
def test_overview_pending_empty_summary(self):
response = self._call('shop_data_crawl_duplicate_check_overview',
'/api/admin/shop-data-crawl/duplicate-check-overview',
{'pending': True, 'scanned_at': '', 'summary': {}, 'shops': []})
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 1) # monitor 只保留跨店重复
self.assertEqual(body['items'][0]['asin'], 'A0000001')
self.assertTrue(body['pending'])
self.assertEqual(body['summary'], {})
def test_overview_force_passed_through(self):
captured = {}
data = dict(self.overview_all)
def side_effect(*args, **kwargs):
captured['params'] = kwargs.get('params')
captured['timeout'] = kwargs.get('timeout')
return _java_ok(data)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview?force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
response = admin_api.shop_data_crawl_duplicate_check_overview()
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual((captured['params'] or {}).get('force'), '1')
# force 同步扫描需要长超时
self.assertEqual(captured['timeout'], (10, 1800))
def test_overview_scan_conflict_409(self):
def side_effect(*args, **kwargs):
return _java_fail_json(409, '扫描进行中,请稍后刷新')
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview?force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
response = admin_api.shop_data_crawl_duplicate_check_overview()
self.assertEqual(response[1], 409)
self.assertFalse(response[0].get_json()['success'])
self.assertEqual(response[0].get_json()['error'], '扫描进行中,请稍后刷新')
def test_items_matrix_monitor_total_and_columns(self):
data = {'pending': False, 'items': self.items_all[:3], 'shops': self.shops,
'total': 3, 'page': 1, 'page_size': 20, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_items',
'/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor',
data)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 3)
self.assertEqual([shop['shop_name'] for shop in body['shops']],
['ShopA', 'ShopB', 'ShopC', 'ShopD'])
def test_items_all_view_includes_unique_asins(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all')
body = response.get_json()
self.assertEqual(body['total'], 4)
def test_items_all_view_passthrough(self):
data = {'pending': False, 'items': self.items_all, 'shops': self.shops,
'total': 6, 'page': 1, 'page_size': 20, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_items',
'/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
data)
self.assertEqual(response.get_json()['total'], 6)
def test_items_filter_by_asin_and_site(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&asin=A0000001&site=UK')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'A0000001')
def test_items_shop_name_alias_merged_and_camel_params(self):
captured = {}
def test_leader_sees_only_own_group_shops(self):
# 主管 id=653 只管理 GroupA(含 ShopA/ShopB
with patch.object(admin_api, '_shop_data_managed_shop_names',
return_value={'shopa', 'shopb'}):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
role='admin', current_row={'id': 653})
body = response.get_json()
self.assertEqual([shop['shop_name'] for shop in body['shops']], ['ShopA', 'ShopB'])
self.assertEqual(body['total'], 2) # A0000001(跨店)+ B0000001(唯一)
# 跨店 ASIN 在主管范围内仍是 2 家店
item = next(i for i in body['items'] if i['asin'] == 'A0000001')
self.assertEqual(item['shop_count'], 2)
def side_effect(*args, **kwargs):
captured['params'] = kwargs.get('params') or {}
return _java_ok({'pending': False, 'items': [], 'shops': [],
'total': 0, 'page': 1, 'page_size': 20, 'scanned_at': ''})
def test_leader_overview_stats_recomputed_after_filter(self):
# 主管见 2 家店:唯一ASIN 2、上架记录 3、重复ASIN 1、重复店铺 2
with patch.object(admin_api, '_shop_data_managed_shop_names',
return_value={'shopa', 'shopb'}):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
with self.app.test_request_context(
'/api/admin/shop-data-crawl/duplicate-check-items?page=2&page_size=50&view=all&shop=ShopA&asin=abc'):
# 内部代理 operatorId 从 flask session 取当前登录管理员
admin_api.session['user_id'] = 1
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('admin', {'id': 653}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('admin', {'id': 653}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_overview()
body = response.get_json()
self.assertEqual(body['summary']['shop_count'], 2)
self.assertEqual(body['summary']['asin_total'], 2)
self.assertEqual(body['summary']['record_total'], 3)
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
admin_api.shop_data_crawl_duplicate_check_items()
params = captured['params']
self.assertEqual(params['page'], '2')
self.assertEqual(params['pageSize'], '50')
self.assertEqual(params['shopName'], 'ShopA') # shop 别名合并进 shop_name→shopName
self.assertEqual(params['asin'], 'abc')
self.assertIn('operatorId', params)
def test_leader_sees_nothing_when_no_group(self):
with patch.object(admin_api, '_shop_data_managed_shop_names', return_value=set()):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
role='admin', current_row={'id': 999999})
body = response.get_json()
self.assertEqual(body['shops'], [])
self.assertEqual(body['total'], 0)
def test_items_denied_403_message_preserved(self):
def denied_menu(*args, **kwargs):
return ('admin', {'id': 5}, (
admin_api.jsonify({'success': False, 'error': '无权访问店铺数据记录模块'}), 403))
def test_export_csv_contains_filtered_rows(self):
"""导出 CSV:行=上架记录,含 BOM,按筛选裁剪。"""
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?'):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-items'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_export()
self.assertEqual(response.status_code, 200)
text = response.get_data(as_text=True)
self.assertTrue(text.startswith(''))
lines = text.lstrip('').strip().splitlines()
self.assertEqual(lines[0], 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌')
self.assertEqual(len(lines), 3) # header + 两条上架记录(A0000001 两店各一条)
patch.object(admin_api, '_ensure_backend_menu_access', side_effect=denied_menu), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('admin', {'id': 5}, None)), \
patch.object(admin_api, '_proxy_backend_java',
side_effect=lambda *a, **k: _java_ok({})):
response = admin_api.shop_data_crawl_duplicate_check_items()
self.assertEqual(response[1], 403)
body = response[0].get_json()
self.assertEqual(body['error'], '无权访问店铺数据记录模块')
def test_detail_sorted_passthrough(self):
detail_items = [
{'asin': 'E0000001', 'shop_count': 3, 'record_count': 3, 'shop_names': ['ShopA', 'ShopB', 'ShopC'],
'brand': 'BrandE', 'first_date': '2026-08-29 04:34:00', 'occurrences': self.items_all[0]['occurrences']},
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'shop_names': ['ShopA', 'ShopB'],
'brand': 'BrandA', 'first_date': '2026-08-30 00:00:00', 'occurrences': self.items_all[1]['occurrences']},
{'asin': 'F0000001', 'shop_count': 2, 'record_count': 2, 'shop_names': ['ShopB', 'ShopD'],
'brand': 'BrandF', 'first_date': '2026-08-28 00:00:00', 'occurrences': self.items_all[2]['occurrences']},
]
data = {'pending': False, 'items': detail_items, 'total': 3,
'page': 1, 'page_size': 6, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_detail',
'/api/admin/shop-data-crawl/duplicate-check-detail?page=1&page_size=6',
data)
body = response.get_json()
self.assertEqual(body['total'], 3)
self.assertEqual(body['items'][0]['asin'], 'E0000001')
self.assertEqual(body['items'][0]['first_date'], '2026-08-29 04:34:00')
def test_detail_pending(self):
response = self._call('shop_data_crawl_duplicate_check_detail',
'/api/admin/shop-data-crawl/duplicate-check-detail',
{'pending': True, 'items': [], 'total': 0, 'page': 1, 'page_size': 6, 'scanned_at': ''})
body = response.get_json()
self.assertTrue(body['pending'])
self.assertEqual(body['items'], [])
def test_export_streams_java_csv(self):
csv_bytes = ('' + 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌\r\n'
'E0000001,3,ShopA,GroupA,UK,2026年8月29日 上午4:34,GBP 12.00,BrandE\r\n').encode('utf-8')
class FakeResp:
status_code = 200
headers = {'Content-Disposition': 'attachment; filename="shop-data-duplicate-check.csv"',
'Content-Type': 'text/csv; charset=utf-8'}
def iter_content(self, chunk_size=1):
yield csv_bytes
def close(self):
pass
def json(self):
return {}
class FakeSession:
def get(self, *args, **kwargs):
return FakeResp()
def test_export_monitor_view_excludes_unique_asins(self):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?view=monitor'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
patch.object(admin_api, 'get_current_admin_role',
return_value=('super_admin', {'id': 1})), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_get_backend_java_session', return_value=FakeSession()):
response = admin_api.shop_data_crawl_duplicate_check_export()
lines = response.get_data(as_text=True).lstrip('').strip().splitlines()
self.assertEqual(len(lines), 3)
self.assertIn('A0000001', lines[1])
body = b''.join(response.response)
self.assertTrue(body.startswith(b'\xef\xbb\xbf'))
self.assertIn('ASIN,店铺数'.encode('utf-8'), body)
self.assertIn('E0000001'.encode('utf-8'), body)
def test_internal_request_falls_back_to_system_operator(self):
"""无请求上下文(定时扫描线程)时,内部请求用系统级超管作为 operatorId。"""
with patch('utils.auth.session', {'user_id': 1}): # 仅用于模拟无异常环境
# 无请求上下文:has_request_context() 为 False,直接走 _resolve_system_operator_id
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
patch.object(admin_api, '_resolve_system_operator_id', return_value=7):
headers, params = admin_api._backend_java_internal_request()
self.assertEqual(headers.get('X-Internal-Token'), 'test-token')
self.assertEqual(params, {'operatorId': 7})
def test_export_no_scan_400(self):
class FakeResp:
status_code = 400
headers = {}
def test_internal_request_system_operator_missing_fails(self):
"""无请求上下文且系统中没有任何管理员时,直接报错而不是传 0。"""
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
patch.object(admin_api, '_resolve_system_operator_id', return_value=None):
with self.assertRaises(ValueError):
admin_api._backend_java_internal_request()
def json(self):
return {'success': False, 'message': '暂无扫描结果,请先点击「重新分析」'}
def test_fetch_result_bytes_rejects_json_error_body(self):
"""Java 内部端点返回 JSON 错误体(如鉴权失败)时,抛出业务错误而不是 BadZipFile。"""
fake_response = Mock()
fake_response.raise_for_status = Mock()
fake_response.iter_content = Mock(return_value=[
'{"success":false,"message":"用户不存在","data":null,"code":401}'.encode('utf-8')])
fake_response.close = Mock()
with patch.object(admin_api, '_backend_java_internal_request',
return_value=({'X-Internal-Token': 'test-token'}, {'operatorId': 7})), \
patch.object(admin_api, '_get_backend_java_session') as fake_session:
fake_session.return_value.get = Mock(return_value=fake_response)
with self.assertRaises(ValueError) as ctx:
admin_api._shop_data_crawl_fetch_result_bytes({'result_id': 123})
self.assertIn('用户不存在', str(ctx.exception))
def close(self):
pass
def iter_content(self, chunk_size=1):
return iter(())
class FakeSession:
def get(self, *args, **kwargs):
return FakeResp()
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_current_admin_role',
return_value=('super_admin', {'id': 1})), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_get_backend_java_session', return_value=FakeSession()):
response = admin_api.shop_data_crawl_duplicate_check_export()
self.assertEqual(response[1], 400)
self.assertEqual(response[0].get_json()['error'], '暂无扫描结果,请先点击「重新分析」')
if __name__ == '__main__':
+123 -20
View File
@@ -1337,47 +1337,69 @@
min-height: 120px;
max-height: 250px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 7px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
gap: 8px;
padding-right: 2px;
}
.dup-chart-row {
display: grid;
grid-template-columns: minmax(70px, 96px) minmax(0, 1fr) 44px;
align-items: center;
gap: 9px;
/* 店铺上架分布:双列网格(店名 + 记录数 / 进度条 / N 个 ASIN */
.dup-dist-item {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: #fbfcfe;
border: 1px solid var(--c-border);
border-radius: 8px;
}
.dup-chart-name {
font-size: 12px;
color: var(--c-text-2);
.dup-dist-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.dup-dist-name {
min-width: 0;
font-size: 12.5px;
font-weight: 700;
color: var(--c-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dup-chart-track {
height: 16px;
.dup-dist-record {
flex: 0 0 auto;
font-size: 15px;
font-weight: 800;
color: var(--c-primary-strong);
font-variant-numeric: tabular-nums;
}
.dup-dist-track {
height: 7px;
background: #eef0f6;
border-radius: 5px;
border-radius: 999px;
overflow: hidden;
}
.dup-chart-fill {
.dup-dist-fill {
display: block;
height: 100%;
min-width: 2px;
background: linear-gradient(90deg, var(--c-primary), #8b8ff3);
border-radius: 5px;
border-radius: 999px;
transition: width 0.3s ease;
}
.dup-chart-num {
.dup-dist-asin {
font-size: 12px;
font-weight: 700;
color: var(--c-text);
text-align: right;
color: var(--c-text-3);
font-variant-numeric: tabular-nums;
}
@@ -1532,6 +1554,82 @@
border: 1px solid var(--c-border);
}
/* 国家彩色徽章(撞款详情卡片 / 抽屉):DE橙 / UK靛蓝 / FR蓝 / IT绿 / ES红 / 其他兜底 */
.dup-site-badge {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
vertical-align: middle;
}
.dup-site-DE { background: #fff1e3; color: #c2570a; border: 1px solid #f3cba4; }
.dup-site-UK { background: #eeedfb; color: #5451d6; border: 1px solid #c9c7f2; }
.dup-site-FR { background: #e5f0fd; color: #1d5fc2; border: 1px solid #b5d3f5; }
.dup-site-IT { background: #e3f6ec; color: #18774c; border: 1px solid #aadcc2; }
.dup-site-ES { background: #fdeaea; color: #c03a2e; border: 1px solid #f2bcba; }
.dup-site-NA,
.dup-site- { background: #f4f6fa; color: var(--c-text-3); border: 1px solid var(--c-border); }
/* 抽屉底部徽章组:彩色滴状标签 */
.dup-drawer-inline-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.dup-drawer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 11px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
background: #eef2f7;
color: var(--c-text-2);
}
.dup-drawer-badge-danger { background: #fdecec; color: #d8483a; border: 1px solid #f3bcb8; }
.dup-drawer-badge-blue { background: var(--c-primary-soft); color: var(--c-primary-strong); border: 1px solid #d3d6fa; }
.dup-drawer-badge-green { background: #e3f6ec; color: #18774c; border: 1px solid #aadcc2; }
.dup-drawer-badge-cyan { background: #e0f5f9; color: #0e7d94; border: 1px solid #a8e0ec; }
/* 抽屉头部 ASIN 与品牌行 */
.dup-drawer-asin {
font-family: Consolas, Menlo, monospace;
font-size: 17px;
font-weight: 800;
color: var(--c-primary-strong);
letter-spacing: 0.02em;
}
.dup-drawer-brand-line {
padding: 12px 0;
font-size: 13px;
color: var(--c-text);
border-bottom: 1px solid var(--c-border);
overflow-wrap: anywhere;
}
.dup-check-drawer-table td.dup-date {
color: var(--c-text-2);
font-variant-numeric: tabular-nums;
}
.drawer-subtitle.dup-drawer-subtitle {
color: var(--c-text-2);
font-size: 12.5px;
}
.dup-check-drawer-meta {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
@@ -1598,6 +1696,10 @@
.dup-check-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dup-check-distribution-bars {
grid-template-columns: minmax(0, 1fr);
}
}
.thumb {
@@ -5585,6 +5687,7 @@
<span class="dup-check-detail-sub">同一 ASIN 在多条店铺的上架明细</span>
</div>
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
</div>
<div class="drawer-mask" id="dupCheckDrawerMask">
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">