From c9efdf8b0e505ea974b50f363a987921bc35a2d6 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Wed, 10 Jun 2026 17:52:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95=E6=B6=88?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CollectDataSubmitResultRequest.java | 4 ++ .../model/dto/CollectDataSummaryRowDto.java | 54 +++++++++++++++++++ .../CollectDataExcelAssemblyService.java | 50 ++++++++++++++--- .../service/CollectDataService.java | 26 ++++++++- .../pages/brand/components/BrandTopBar.vue | 37 ++++++++++--- frontend-vue/src/shared/api/permission.ts | 48 +++++++++++++---- 6 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSummaryRowDto.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java index 71cdabc..20895c4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java @@ -38,4 +38,8 @@ public class CollectDataSubmitResultRequest { @JsonAlias({"rows", "data", "items"}) @Schema(description = "本分片回传数据行") private List items = new ArrayList<>(); + + @JsonAlias({"summary", "summary_rows", "汇总"}) + @Schema(description = "Python 端按关键词聚合的配送方式 / 页数统计;通常仅在 done=true 时携带,空数组等同于不携带,Java 回退到自聚合 rawItems。") + private List summaries = new ArrayList<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSummaryRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSummaryRowDto.java new file mode 100644 index 0000000..b9b310b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSummaryRowDto.java @@ -0,0 +1,54 @@ +package com.nanri.aiimage.modules.collectdata.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "采集数据 Python 关键词级聚合统计行,通常仅在 done=true 时携带") +public class CollectDataSummaryRowDto { + + /** + * 关键词原文。 + */ + @JsonAlias({"关键词", "key_word", "key word"}) + @Schema(description = "关键词原文", example = "phone case") + private String keyword; + + /** + * 该关键词命中 FBA 配送方式的行数。 + */ + @Schema(description = "FBA 行数", example = "12") + private Integer fba; + + /** + * 该关键词命中 FBM 配送方式的行数。 + */ + @Schema(description = "FBM 行数", example = "8") + private Integer fbm; + + /** + * 该关键词命中 AMZ 配送方式的行数。 + */ + @Schema(description = "AMZ 行数", example = "5") + private Integer amz; + + /** + * 该关键词「无配送方式」(未识别)行数。 + */ + @JsonProperty("none_count") + @JsonAlias({"none", "unknown", "无配送方式", "noneCount"}) + @Schema(description = "无配送方式(未识别 FBA/FBM/AMZ)行数", example = "3") + private Integer noneCount; + + /** + * 该关键词所有命中行的最大页码,由 Python 抓取端汇总。 + */ + @JsonProperty("total_page") + @JsonAlias({"page", "totalPage", "页数", "max_page", "maxPage"}) + @Schema(description = "该关键词总页数(Python 端取所有命中行的最大页码)", example = "10") + private Integer totalPage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java index e665813..5a42da2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.collectdata.service; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; @@ -30,14 +31,18 @@ public class CollectDataExcelAssemblyService { * * @param outputXlsx 目标文件 * @param items 经过 ASIN 去重 / 无效品牌过滤 / 品牌检测后保留下来的明细行,写入「采集数据结果」sheet - * @param rawItems Python 回传的全量原始行(不经后端任何过滤),写入「结果文件」sheet 的关键词聚合统计 + * @param summaries Python 在 done=true 时携带的关键词级聚合统计;非空时优先使用,直接落「结果文件」sheet + * @param rawItems Python 回传的全量原始行(不经后端任何过滤);仅在 summaries 为空时作为 fallback 自聚合 */ - public void writeWorkbook(File outputXlsx, List items, List rawItems) { + public void writeWorkbook(File outputXlsx, + List items, + List summaries, + List rawItems) { SXSSFWorkbook workbook = new SXSSFWorkbook(200); workbook.setCompressTempFiles(true); try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) { writeDetailSheet(workbook, items); - writeSummarySheet(workbook, rawItems); + writeSummarySheet(workbook, summaries, rawItems); workbook.write(outputStream); } catch (Exception ex) { log.warn("[collect-data] write workbook failed: {}", ex.getMessage()); @@ -76,11 +81,14 @@ public class CollectDataExcelAssemblyService { } /** - * 「结果文件」sheet 基于 Python 回传的全量原始数据按关键词聚合: - * - FBA / FBM / AMZ / 无配送方式 列为各分类的命中行数; - * - 页数列取该关键词所有原始行中页码的最大值,反映 Python 实际抓取到的总页数。 + * 「结果文件」sheet 的关键词级聚合: + * - 优先使用 Python 在 done=true 时携带的 summaries,避免 Java 端二次聚合带来的口径漂移; + * - 当 summaries 为空(Python 端尚未接入或解析失败)时,回退到基于 rawItems 自聚合, + * 保证 Excel 不会因迁移过渡期而出现空表。等 Python 端全部接入并稳定后,可移除 fallback 分支。 */ - private void writeSummarySheet(SXSSFWorkbook workbook, List rawItems) { + private void writeSummarySheet(SXSSFWorkbook workbook, + List summaries, + List rawItems) { Sheet sheet = workbook.createSheet(SHEET_SUMMARY_NAME); Row headerRow = sheet.createRow(0); for (int i = 0; i < SHEET_SUMMARY_HEADER.length; i++) { @@ -88,6 +96,30 @@ public class CollectDataExcelAssemblyService { sheet.setColumnWidth(i, (i == 0 ? 28 : 12) * 256); } + if (summaries != null && !summaries.isEmpty()) { + // 优先分支:Python 携带的关键词级聚合直接落表,null 字段统一兜底为 0;页数 ≤0 写空字符串。 + int rowIndex = 1; + for (CollectDataSummaryRowDto item : summaries) { + if (item == null) { + continue; + } + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(safe(item.getKeyword())); + row.createCell(1).setCellValue(intOrZero(item.getFba())); + row.createCell(2).setCellValue(intOrZero(item.getFbm())); + row.createCell(3).setCellValue(intOrZero(item.getAmz())); + row.createCell(4).setCellValue(intOrZero(item.getNoneCount())); + Integer totalPage = item.getTotalPage(); + if (totalPage != null && totalPage > 0) { + row.createCell(5).setCellValue(totalPage); + } else { + row.createCell(5).setCellValue(""); + } + } + return; + } + + // Fallback 分支:基于 rawItems 自聚合(Python 端未接入时使用)。 Map grouped = new LinkedHashMap<>(); if (rawItems != null) { for (CollectDataResultRowVo item : rawItems) { @@ -131,6 +163,10 @@ public class CollectDataExcelAssemblyService { return value == null ? "" : value; } + private int intOrZero(Integer value) { + return value == null ? 0 : value; + } + private static class KeywordSummary { int fba; int fbm; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index c8ba548..fb78aec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -18,6 +18,7 @@ import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSourceFileDto; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitRowDto; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto; import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity; import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo; @@ -454,6 +455,11 @@ public class CollectDataService { persistScope(taskId, scopeKey, scopeHash, chunkTotal, request); stats.finalRowCount = countFinalRows(taskId); + // Python 在 done=true 那次回传携带关键词级聚合统计;非空时按"最后一次为准"覆盖。 + List incomingSummaries = request.getSummaries(); + if (incomingSummaries != null && !incomingSummaries.isEmpty()) { + stats.summaries = new ArrayList<>(incomingSummaries); + } persistStats(task, stats); if (request.getError() != null && !request.getError().isBlank()) { @@ -812,6 +818,8 @@ public class CollectDataService { if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { throw new BusinessException("结果记录不存在"); } + // 先加载 stats,使 Python 携带的 summaries 可优先用于「结果文件」sheet;rawRows 仅作为 fallback。 + CollectDataStats stats = loadStats(task); List rows = loadFinalRows(task.getId()); // Sheet「结果文件」按需求基于 Python 回传的全量数据聚合,不经后端 ASIN/品牌过滤丢弃, // 因此从 biz_task_chunk 反序列化全部原始行。 @@ -820,7 +828,7 @@ public class CollectDataService { String filename = buildResultFilename(task, result); File xlsx = FileUtil.file(workRoot, filename); try { - excelAssemblyService.writeWorkbook(xlsx, rows, rawRows); + excelAssemblyService.writeWorkbook(xlsx, rows, stats.summaries, rawRows); String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); result.setResultFilename(filename); result.setResultFileUrl(objectKey); @@ -831,7 +839,7 @@ public class CollectDataService { result.setErrorMessage(null); fileResultMapper.updateById(result); - CollectDataStats stats = loadStats(task); + // 复用同一份 stats 更新 finalRowCount 后再持久化,避免重复 loadStats 丢失 summaries。 stats.finalRowCount = rows.size(); persistStats(task, stats); task.setStatus(STATUS_SUCCESS); @@ -998,6 +1006,16 @@ public class CollectDataService { stats.brandRejectedCount = root.path("brandRejectedCount").asInt(0); stats.brandQueryFailedCount = root.path("brandQueryFailedCount").asInt(0); stats.finalRowCount = root.path("finalRowCount").asInt(0); + // 反序列化 Python 携带的关键词级聚合;解析失败保持空 list,仅 warn 不抛,避免影响其他统计 + JsonNode summariesNode = root.get("summaries"); + if (summariesNode != null && summariesNode.isArray()) { + try { + stats.summaries = objectMapper.convertValue(summariesNode, + new TypeReference>() {}); + } catch (Exception convertEx) { + log.warn("[collect-data] parse summaries failed taskId={} err={}", task.getId(), convertEx.getMessage()); + } + } } catch (Exception ex) { log.warn("[collect-data] parse result stats failed taskId={} err={}", task.getId(), ex.getMessage()); } @@ -1017,6 +1035,8 @@ public class CollectDataService { payload.put("brandRejectedCount", stats.brandRejectedCount); payload.put("brandQueryFailedCount", stats.brandQueryFailedCount); payload.put("finalRowCount", stats.finalRowCount); + // 持久化 Python 携带的关键词级聚合;后续 processResultFileJob 阶段读取用于落 Excel + payload.put("summaries", stats.summaries == null ? List.of() : stats.summaries); task.setResultJson(objectMapper.writeValueAsString(payload)); } catch (Exception ex) { throw new BusinessException("保存采集统计失败"); @@ -1129,6 +1149,8 @@ public class CollectDataService { private int brandRejectedCount; private int brandQueryFailedCount; private int finalRowCount; + // Python 在 done=true 时携带的关键词级聚合统计;空表示由 Java 端用 rawItems 自聚合兜底 + private List summaries = new ArrayList<>(); } @Transactional diff --git a/frontend-vue/src/pages/brand/components/BrandTopBar.vue b/frontend-vue/src/pages/brand/components/BrandTopBar.vue index ecbaa42..62b802b 100644 --- a/frontend-vue/src/pages/brand/components/BrandTopBar.vue +++ b/frontend-vue/src/pages/brand/components/BrandTopBar.vue @@ -71,6 +71,8 @@ type NavItem = { key: string label: string href?: string + columnKey?: string + aliases?: ReadonlyArray } type NavGroup = { @@ -79,17 +81,21 @@ type NavGroup = { items: ReadonlyArray } -const props = defineProps<{ +const props = withDefaults(defineProps<{ active: ActiveNavKey showNav?: boolean showHomeLink?: boolean showActions?: boolean -}>() +}>(), { + showNav: true, + showHomeLink: true, + showActions: true, +}) const active = props.active -const showNav = props.showNav ?? true -const showHomeLink = props.showHomeLink ?? true -const showActions = props.showActions ?? true +const showNav = props.showNav +const showHomeLink = props.showHomeLink +const showActions = props.showActions const allowedColumnKeys = ref(null) const navGroups: ReadonlyArray = [ @@ -115,7 +121,7 @@ const navGroups: ReadonlyArray = [ { key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' }, { key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' }, { key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, - { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' }, + { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] }, { key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' }, { key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' }, { key: 'withdraw', label: '取款' }, @@ -132,12 +138,29 @@ const navGroups: ReadonlyArray = [ }, ] +function getItemPermissionKeys(item: NavItem) { + return [item.key, item.columnKey, ...(item.aliases || [])] + .map((key) => String(key || '').trim().toLowerCase()) + .filter(Boolean) +} + const visibleNavGroups = computed(() => { if (allowedColumnKeys.value === null) { return navGroups } const allowedSet = new Set(allowedColumnKeys.value) - return navGroups.filter((group) => allowedSet.has(group.columnKey)) + + const groups = navGroups.flatMap((group) => { + if (allowedSet.has(group.columnKey)) { + return [group] + } + const items = group.items.filter((item) => + getItemPermissionKeys(item).some((key) => allowedSet.has(key)), + ) + return items.length ? [{ ...group, items }] : [] + }) + + return groups.length ? groups : navGroups }) onMounted(async () => { diff --git a/frontend-vue/src/shared/api/permission.ts b/frontend-vue/src/shared/api/permission.ts index 878efeb..730331b 100644 --- a/frontend-vue/src/shared/api/permission.ts +++ b/frontend-vue/src/shared/api/permission.ts @@ -4,7 +4,9 @@ export interface PermissionMenuItem { id: number | string name?: string column_key?: string + columnKey?: string route_path?: string + routePath?: string menu_type?: string sort_order?: number created_at?: string @@ -12,8 +14,10 @@ export interface PermissionMenuItem { interface PermissionMenuResponse { success: boolean + data?: PermissionMenuItem[] items?: PermissionMenuItem[] error?: string + message?: string } function getCurrentUserId() { @@ -29,6 +33,23 @@ function getAppPermissionCacheKey(uid: number) { return `app_column_permissions:${String(uid)}` } +function getAuthToken() { + return typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || '' +} + +function normalizeColumnKeys(items: PermissionMenuItem[] | undefined) { + const keys = new Set() + for (const item of items || []) { + for (const value of [item.column_key, item.columnKey, item.route_path, item.routePath]) { + const key = String(value || '').trim().toLowerCase() + if (key) { + keys.add(key) + } + } + } + return Array.from(keys) +} + export async function getCurrentUserAppColumnKeys() { const uid = getCurrentUserId() const cacheKey = getAppPermissionCacheKey(uid) @@ -36,28 +57,35 @@ export async function getCurrentUserAppColumnKeys() { try { const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null if (Array.isArray(cachedItems)) { - return cachedItems - .map((item) => String(item.column_key || '').trim().toLowerCase()) - .filter(Boolean) + const cachedKeys = normalizeColumnKeys(cachedItems) + if (cachedKeys.length) { + return cachedKeys + } } } catch (_error) {} + const headers: Record = {} + const token = getAuthToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + const res = await requestGetJson( - `/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`, + `/newApi/api/admin/permission-users/${encodeURIComponent(String(uid))}/column-permissions`, { - params: { menu_type: 'app' }, + params: { menuType: 'app' }, + headers, }, ) if (!res.success) { - throw new Error(res.error || '获取菜单权限失败') + throw new Error(res.error || res.message || '获取菜单权限失败') } + const items = res.data || res.items || [] try { - window.localStorage.setItem(cacheKey, JSON.stringify(res.items || [])) + window.localStorage.setItem(cacheKey, JSON.stringify(items)) } catch (_error) {} - return (res.items || []) - .map((item) => String(item.column_key || '').trim().toLowerCase()) - .filter(Boolean) + return normalizeColumnKeys(items) }