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
@@ -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("");
}
}