ids = new LinkedHashSet<>();
+ for (Object item : items) {
+ long value;
+ if (item instanceof Number number) {
+ value = number.longValue();
+ } else if (item instanceof String text && text.matches("\\d+")) {
+ value = Long.parseLong(text);
+ } else {
+ throw new BusinessException(400, "结果文件参数无效");
+ }
+ if (value <= 0) {
+ throw new BusinessException(400, "结果文件参数无效");
+ }
+ ids.add(value);
+ }
+ return new ArrayList<>(ids);
+ }
+
+ private void requireShopDataCrawlTaskAccess(HttpServletRequest request) {
+ AdminUserEntity operator;
+ try {
+ operator = adminAuthSupport.requireAdmin(request);
+ } catch (BusinessException authFailure) {
+ operator = resolveInternalOperator(request);
+ if (operator == null) {
+ throw authFailure;
+ }
+ }
+ permissionMenuService.requireShopDataCrawlTaskAccess(operator);
+ }
+
+ private AdminUserEntity resolveInternalOperator(HttpServletRequest request) {
+ String suppliedToken = request.getHeader("X-Internal-Token");
+ if (!isTrustedInternalRequest(suppliedToken)) {
+ return null;
+ }
+ String rawOperatorId = request.getParameter("operatorId");
+ if (rawOperatorId == null || rawOperatorId.isBlank()) {
+ rawOperatorId = request.getParameter("operator_id");
+ }
+ if (rawOperatorId == null || rawOperatorId.isBlank()) {
+ return null;
+ }
+ try {
+ return permissionMenuService.requireAdminOperator(Long.parseLong(rawOperatorId.trim()));
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private boolean isTrustedInternalRequest(String suppliedToken) {
+ String expectedToken = resolveExpectedInternalToken();
+ return !expectedToken.isBlank() && suppliedToken != null && !suppliedToken.isBlank()
+ && MessageDigest.isEqual(
+ expectedToken.getBytes(StandardCharsets.UTF_8),
+ suppliedToken.trim().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String resolveExpectedInternalToken() {
+ if (internalToken != null && !internalToken.isBlank()) {
+ return internalToken.trim();
+ }
+ Path path = resolveInternalTokenFile();
+ if (path == null || !Files.isRegularFile(path)) {
+ return "";
+ }
+ try {
+ return Files.readString(path, StandardCharsets.UTF_8).trim();
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ private Path resolveInternalTokenFile() {
+ String configuredPath = internalTokenFile == null ? "" : internalTokenFile.trim();
+ if (!configuredPath.isEmpty()) {
+ if (configuredPath.equals("~") || configuredPath.startsWith("~/") || configuredPath.startsWith("~\\")) {
+ String userHome = System.getProperty("user.home", "").trim();
+ if (userHome.isEmpty()) {
+ return null;
+ }
+ configuredPath = configuredPath.length() == 1
+ ? userHome
+ : Path.of(userHome, configuredPath.substring(2)).toString();
+ }
+ Path configuredTokenPath = Path.of(configuredPath);
+ return configuredTokenPath.isAbsolute() ? configuredTokenPath.normalize() : null;
+ }
+ String userHome = System.getProperty("user.home", "").trim();
+ return userHome.isEmpty()
+ ? null
+ : Path.of(userHome, ".aiimage", "internal-token").toAbsolutePath().normalize();
+ }
+
+ private static int parseInt(String raw, int defaultValue, String errorMessage) {
+ if (raw == null || raw.isBlank()) {
+ return defaultValue;
+ }
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException ex) {
+ throw new BusinessException(400, errorMessage);
+ }
+ }
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java
new file mode 100644
index 00000000..8466498e
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java
@@ -0,0 +1,185 @@
+package com.nanri.aiimage.modules.shopdatacrawl.mapper;
+
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminFileJobBriefDto;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupLabelDto;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupRow;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminRow;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.SelectProvider;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 店铺数据抓取记录"按店铺分组列表"与批量下载的数据源查询,
+ * 语义逐条对照 Flask admin_api.py 中 list_shop_data_crawl_tasks /
+ * _load_shop_data_crawl_download_rows 的 SQL。
+ *
+ * 参数统一走单参 Map:筛选与分页字段用语义键(shopName/groupName/country/
+ * createdFrom/createdTo/pageLimit/pageOffset),IN 列表键 shopNames / resultIds,
+ * 占位元素键为 sn0..snN / ri0..riN(调用方在 Service 内按序铺平,避免动态列表绑定歧义)。
+ */
+@Mapper
+public interface ShopDataCrawlAdminTasksMapper {
+
+ /** 店铺分组总数(按 TRIM(source_filename) 去重后的组数)。 */
+ @SelectProvider(type = SqlProvider.class, method = "countShopGroups")
+ long countShopGroups(Map p);
+
+ /** 当前页店铺分组(每店最新归档时间)。 */
+ @SelectProvider(type = SqlProvider.class, method = "selectShopGroupPage")
+ List selectShopGroupPage(Map p);
+
+ /** 当前页每家店铺的最新结果行(窗口函数 row_number=1),需先铺平 shopNames。 */
+ @SelectProvider(type = SqlProvider.class, method = "selectLatestRowsForShops")
+ List selectLatestRowsForShops(Map p);
+
+ /** 店铺 → 分组名映射,需先铺平 shopNames。 */
+ @SelectProvider(type = SqlProvider.class, method = "selectGroupLabels")
+ List selectGroupLabels(Map p);
+
+ /** 每个结果文件最新一条 ASSEMBLE_RESULT 任务(按 job id 取最大),需先铺平 resultIds。 */
+ @SelectProvider(type = SqlProvider.class, method = "selectLatestAssembleJobs")
+ List selectLatestAssembleJobs(Map p);
+
+ /** 批量下载 zip 所需结果行,需先铺平 resultIds。 */
+ @SelectProvider(type = SqlProvider.class, method = "selectDownloadRows")
+ List selectDownloadRows(Map p);
+
+ class SqlProvider {
+
+ private static final String LATEST_TIME = "COALESCE(df.last_success_at, df.updated_at, "
+ + "t.finished_at, t.updated_at, t.created_at)";
+
+ private static final String SHOP_KEY = "TRIM(COALESCE(r.source_filename, ''))";
+
+ private static final String FROM_WHERE_BASE =
+ " FROM biz_file_result r "
+ + "JOIN biz_file_task t ON t.id = r.task_id "
+ + "LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id "
+ + "LEFT JOIN users u ON u.id = r.user_id "
+ + "WHERE r.module_type = 'SHOP_DATA_CRAWL' "
+ + "AND t.module_type = 'SHOP_DATA_CRAWL' "
+ + "AND TRIM(COALESCE(r.result_file_url, '')) <> ''";
+
+ public String countShopGroups(Map p) {
+ return "SELECT COUNT(*) AS total FROM (SELECT " + SHOP_KEY + " AS shopKey"
+ + FROM_WHERE_BASE + whereTail(p, "") + " GROUP BY " + SHOP_KEY + ") grouped_shops";
+ }
+
+ public String selectShopGroupPage(Map p) {
+ return "SELECT " + SHOP_KEY + " AS shopName, MAX(" + LATEST_TIME + ") AS latestCreatedAt"
+ + FROM_WHERE_BASE + whereTail(p, "")
+ + " GROUP BY " + SHOP_KEY
+ + " ORDER BY latestCreatedAt DESC, shopName ASC"
+ + " LIMIT #{pageLimit} OFFSET #{pageOffset}";
+ }
+
+ public String selectLatestRowsForShops(Map p) {
+ return "SELECT ranked.resultId, ranked.taskId, ranked.userId, ranked.username,"
+ + " ranked.shopName, ranked.shopId, ranked.resultFilename, ranked.resultFileUrl,"
+ + " ranked.resultFileSize, ranked.resultContentType, ranked.rowCount,"
+ + " ranked.resultSuccess, ranked.resultError, ranked.resultCreatedAt,"
+ + " ranked.taskNo, ranked.taskStatus, ranked.requestJson, ranked.taskError,"
+ + " ranked.createdAt, ranked.updatedAt, ranked.finishedAt,"
+ + " ranked.latestFileUpdatedAt, ranked.countryCodesJson, ranked.rowCountDisplay"
+ + " FROM (SELECT r.id AS resultId, r.task_id AS taskId, r.user_id AS userId,"
+ + " u.username AS username,"
+ + " r.source_filename AS shopName, r.source_file_url AS shopId,"
+ + " r.result_filename AS resultFilename, r.result_file_url AS resultFileUrl,"
+ + " r.result_file_size AS resultFileSize, r.result_content_type AS resultContentType,"
+ + " r.row_count AS rowCount, r.success AS resultSuccess,"
+ + " r.error_message AS resultError, r.created_at AS resultCreatedAt,"
+ + " t.task_no AS taskNo, t.status AS taskStatus, t.request_json AS requestJson,"
+ + " t.error_message AS taskError, t.created_at AS createdAt,"
+ + " t.updated_at AS updatedAt, t.finished_at AS finishedAt,"
+ + " " + LATEST_TIME + " AS latestFileUpdatedAt,"
+ + " df.country_codes_json AS countryCodesJson,"
+ + " COALESCE(df.row_count, r.row_count) AS rowCountDisplay,"
+ + " ROW_NUMBER() OVER (PARTITION BY " + SHOP_KEY
+ + " ORDER BY " + LATEST_TIME + " DESC, r.id DESC) AS rowNo"
+ + FROM_WHERE_BASE
+ + whereTail(p, " AND " + SHOP_KEY + " IN (" + inPlaceholders(p, "shopNames", "sn") + ")")
+ + ") ranked WHERE ranked.rowNo = 1"
+ + " ORDER BY ranked.latestFileUpdatedAt DESC, ranked.resultId DESC";
+ }
+
+ public String selectGroupLabels(Map p) {
+ return "SELECT TRIM(sm.shop_name) AS shopName,"
+ + " GROUP_CONCAT(DISTINCT COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, ''))"
+ + " ORDER BY sm.id SEPARATOR '、') AS groupName"
+ + " FROM biz_shop_manage sm"
+ + " LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id"
+ + " WHERE TRIM(sm.shop_name) IN (" + inPlaceholders(p, "shopNames", "sn") + ")"
+ + " GROUP BY TRIM(sm.shop_name)";
+ }
+
+ public String selectLatestAssembleJobs(Map p) {
+ return "SELECT fj.result_id AS resultId, fj.id AS fileJobId,"
+ + " fj.status AS fileStatus, fj.error_message AS fileError"
+ + " FROM biz_task_file_job fj"
+ + " INNER JOIN (SELECT result_id, MAX(id) AS max_id FROM biz_task_file_job"
+ + " WHERE module_type = 'SHOP_DATA_CRAWL' AND job_type = 'ASSEMBLE_RESULT'"
+ + " AND result_id IN (" + inPlaceholders(p, "resultIds", "ri") + ")"
+ + " GROUP BY result_id) latest ON fj.id = latest.max_id";
+ }
+
+ public String selectDownloadRows(Map p) {
+ return "SELECT r.id AS resultId, r.task_id AS taskId, r.user_id AS userId,"
+ + " r.source_filename AS sourceFilename, r.result_filename AS resultFilename,"
+ + " r.result_file_url AS resultFileUrl, t.status AS taskStatus"
+ + " FROM biz_file_result r"
+ + " JOIN biz_file_task t ON t.id = r.task_id"
+ + " WHERE r.module_type = 'SHOP_DATA_CRAWL' AND t.module_type = 'SHOP_DATA_CRAWL'"
+ + " AND r.id IN (" + inPlaceholders(p, "resultIds", "ri") + ")";
+ }
+
+ /** 追加可选的店铺/分组/国家/时间筛选。 */
+ private static String whereTail(Map p, String extraCondition) {
+ StringBuilder sql = new StringBuilder(extraCondition == null ? "" : extraCondition);
+ String shopName = (String) p.get("shopName");
+ if (shopName != null && !shopName.isBlank()) {
+ sql.append(" AND r.source_filename LIKE CONCAT('%', #{shopName}, '%')");
+ }
+ String groupName = (String) p.get("groupName");
+ if (groupName != null && !groupName.isBlank()) {
+ sql.append(" AND EXISTS (SELECT 1 FROM biz_shop_manage sm"
+ + " LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id"
+ + " WHERE TRIM(COALESCE(sm.shop_name, '')) = "
+ + "TRIM(COALESCE(r.source_filename, ''))"
+ + " AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, ''))"
+ + " LIKE CONCAT('%', #{groupName}, '%'))");
+ }
+ if (p.get("country") != null && !((String) p.get("country")).isBlank()) {
+ sql.append(" AND JSON_CONTAINS(COALESCE(df.country_codes_json,"
+ + " JSON_EXTRACT(t.request_json, '$.countryCodes'),"
+ + " JSON_EXTRACT(t.request_json, '$.country_codes'), '[]'),"
+ + " CONCAT('\"', #{country}, '\"'))");
+ }
+ if (p.get("createdFrom") != null) {
+ sql.append(" AND t.created_at >= #{createdFrom}");
+ }
+ if (p.get("createdTo") != null) {
+ sql.append(" AND t.created_at <= #{createdTo}");
+ }
+ return sql.toString();
+ }
+
+ /** 列表长度对应占位符 #{prefix0}, #{prefix1}...;空列表返回空字符串,调用方须保证非空。 */
+ private static String inPlaceholders(Map p, String listKey, String prefix) {
+ Object raw = p.get(listKey);
+ if (!(raw instanceof List> items) || items.isEmpty()) {
+ return "";
+ }
+ StringBuilder sql = new StringBuilder();
+ for (int i = 0; i < items.size(); i++) {
+ if (i > 0) {
+ sql.append(", ");
+ }
+ sql.append("#{").append(prefix).append(i).append('}');
+ }
+ return sql.toString();
+ }
+ }
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java
new file mode 100644
index 00000000..91797a69
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminFileJobBriefDto.java
@@ -0,0 +1,15 @@
+package com.nanri.aiimage.modules.shopdatacrawl.model.dto;
+
+import lombok.Data;
+
+/**
+ * 结果文件最近的 ASSEMBLE_RESULT 文件任务(按 job id 取最新,语义对齐 Flask)。
+ */
+@Data
+public class ShopDataCrawlAdminFileJobBriefDto {
+
+ private Long resultId;
+ private Long fileJobId;
+ private String fileStatus;
+ private String fileError;
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java
new file mode 100644
index 00000000..70a199d7
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupLabelDto.java
@@ -0,0 +1,13 @@
+package com.nanri.aiimage.modules.shopdatacrawl.model.dto;
+
+import lombok.Data;
+
+/**
+ * 店铺 → 分组名(g.group_name 优先于 sm.group_name,多分组「、」连接)。
+ */
+@Data
+public class ShopDataCrawlAdminGroupLabelDto {
+
+ private String shopName;
+ private String groupName;
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java
new file mode 100644
index 00000000..6008fdb0
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminGroupRow.java
@@ -0,0 +1,15 @@
+package com.nanri.aiimage.modules.shopdatacrawl.model.dto;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 按店铺分组列表的一行(店铺名 + 该店全部匹配结果中最新的归档时间),用于分页组集合。
+ */
+@Data
+public class ShopDataCrawlAdminGroupRow {
+
+ private String shopName;
+ private LocalDateTime latestCreatedAt;
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java
new file mode 100644
index 00000000..94d63694
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlAdminRow.java
@@ -0,0 +1,41 @@
+package com.nanri.aiimage.modules.shopdatacrawl.model.dto;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理列表"每店最新结果行"(窗口函数 row_number=1 结果),字段对齐 Flask
+ * _SHOP_DATA_CRAWL_ADMIN_COLUMNS 的列集。
+ */
+@Data
+public class ShopDataCrawlAdminRow {
+
+ private Long resultId;
+ private Long taskId;
+ private Long userId;
+ private String username;
+ /** r.source_filename 原始值(可能带首尾空格,展示用,与 Flask 一致)。 */
+ private String shopName;
+ /** r.source_file_url,语义为店铺页 URL。 */
+ private String shopId;
+ private String resultFilename;
+ private String resultFileUrl;
+ private Long resultFileSize;
+ private String resultContentType;
+ private Integer rowCount;
+ /** 结果成功标志:-1 处理中 / 0 失败 / 1 成功。 */
+ private Integer resultSuccess;
+ private String resultError;
+ private LocalDateTime resultCreatedAt;
+ private String taskNo;
+ private String taskStatus;
+ private String requestJson;
+ private String taskError;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+ private LocalDateTime finishedAt;
+ private LocalDateTime latestFileUpdatedAt;
+ private String countryCodesJson;
+ private Integer rowCountDisplay;
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java
new file mode 100644
index 00000000..2c0ca318
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/model/dto/ShopDataCrawlDownloadRowDto.java
@@ -0,0 +1,18 @@
+package com.nanri.aiimage.modules.shopdatacrawl.model.dto;
+
+import lombok.Data;
+
+/**
+ * 批量下载 zip 需要的行数据(等价 Flask _load_shop_data_crawl_download_rows)。
+ */
+@Data
+public class ShopDataCrawlDownloadRowDto {
+
+ private Long resultId;
+ private Long taskId;
+ private Long userId;
+ private String sourceFilename;
+ private String resultFilename;
+ private String resultFileUrl;
+ private String taskStatus;
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java
new file mode 100644
index 00000000..c2f54aa9
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java
@@ -0,0 +1,333 @@
+package com.nanri.aiimage.modules.shopdatacrawl.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nanri.aiimage.common.exception.BusinessException;
+import com.nanri.aiimage.modules.shopdatacrawl.mapper.ShopDataCrawlAdminTasksMapper;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminFileJobBriefDto;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupLabelDto;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminGroupRow;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlAdminRow;
+import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 店铺数据抓取记录管理端查询组装:按店铺分组、每店取最新结果(等价 Flask
+ * admin_api.py list_shop_data_crawl_tasks),以及批量 zip 的行数据加载。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ShopDataCrawlAdminTasksService {
+
+ private static final DateTimeFormatter ADMIN_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ private static final Set SUPPORTED_COUNTRIES = Set.of("DE", "UK", "FR", "IT", "ES");
+
+ private final ShopDataCrawlAdminTasksMapper adminTasksMapper;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 分页列表组装(组总数 → 当前页店铺组 → 每店最新结果行 + 文件任务/分组名补充)。
+ */
+ public Map listShopGroups(int page, int pageSize, String shopName, String groupName,
+ String country, String createdFromRaw, String createdToRaw) {
+ LocalDateTime createdFrom = parseAdminDateTime("created_from", createdFromRaw);
+ LocalDateTime createdTo = parseAdminDateTime("created_to", createdToRaw);
+ String countryFilter = blankToNull(country);
+ if (countryFilter != null) {
+ countryFilter = countryFilter.trim().toUpperCase(Locale.ROOT);
+ if (!SUPPORTED_COUNTRIES.contains(countryFilter)) {
+ throw new BusinessException(400, "不支持的国家代码: " + countryFilter);
+ }
+ }
+ Map p = new HashMap<>();
+ p.put("shopName", blankToNull(shopName));
+ p.put("groupName", blankToNull(groupName));
+ p.put("country", countryFilter);
+ p.put("createdFrom", createdFrom);
+ p.put("createdTo", createdTo);
+
+ long total = adminTasksMapper.countShopGroups(p);
+ List