From 1fdef3c67f3c83742abeea3a84bb34d78f9c416b Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Thu, 2 Apr 2026 01:15:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B4=AB=E9=B8=9F=E6=9F=A5=E8=AF=A2=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiimage/config/ZiniaoProperties.java | 21 + .../model/vo/DeleteBrandResultItemVo.java | 6 + .../model/vo/DeleteBrandTaskDetailVo.java | 6 + .../service/DeleteBrandRunService.java | 142 +++++- .../shopkey/service/ShopKeyService.java | 15 + .../controller/ZiniaoAuthController.java | 9 + .../service/ZiniaoMemoryStoreService.java | 44 ++ .../model/cache/ZiniaoShopIndexEntryDto.java | 24 + .../ZiniaoShopIndexRefreshCursorDto.java | 18 + .../model/vo/ZiniaoShopMatchResultVo.java | 6 + .../ziniao/service/ZiniaoAuthService.java | 58 +++ .../ZiniaoShopIndexRefreshService.java | 23 + .../service/ZiniaoShopIndexService.java | 414 ++++++++++++++++++ .../service/ZiniaoShopSwitchService.java | 28 +- .../src/main/resources/application.yml | 8 +- .../brand/components/BrandDeleteBrandTab.vue | 116 +++-- frontend-vue/src/shared/api/java-modules.ts | 4 + 17 files changed, 891 insertions(+), 51 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexEntryDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexRefreshCursorDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexRefreshService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java index a055d93..ad9ba59 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java @@ -40,4 +40,25 @@ public class ZiniaoProperties { * null/空/<=0 表示不限制(不推荐)。 */ private Integer keyScanMaxSeconds; + + /** + * 店铺索引后台刷新 cron。 + */ + private String shopIndexRefreshCron; + + /** + * 店铺索引条目保鲜分钟数。 + */ + private Integer shopIndexFreshMinutes; + + /** + * 店铺索引条目 TTL 小时数。 + */ + private Integer shopIndexEntryTtlHours; + + /** + * 每次索引刷新最多扫描多少个 apiKey; + * null/空/<=0 表示不限制(不推荐)。 + */ + private Integer shopIndexRefreshBatchSize; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java index 140563a..c95bbaf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java @@ -33,6 +33,12 @@ public class DeleteBrandResultItemVo { @Schema(description = "紫鸟打开链接") private String openStoreUrl; + @Schema(description = "匹配状态: MATCHED / PENDING / CONFLICT") + private String matchStatus; + + @Schema(description = "匹配说明") + private String matchMessage; + @Schema(description = "总行数") private Integer totalRows; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java index 091fc18..9bf9829 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java @@ -3,6 +3,9 @@ package com.nanri.aiimage.modules.deletebrand.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.ArrayList; +import java.util.List; + @Data @Schema(description = "删除品牌任务详情响应") public class DeleteBrandTaskDetailVo { @@ -11,4 +14,7 @@ public class DeleteBrandTaskDetailVo { @Schema(description = "实时行级进度信息,来自 Redis") private DeleteBrandLineProgressVo line_progress; + + @Schema(description = "任务结果项快照") + private List items = new ArrayList<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index 93da7b3..d63acc1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -33,6 +33,7 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -125,14 +126,14 @@ public class DeleteBrandRunService { ZiniaoShopMatchResultVo matchResult = normalizedShopName.isBlank() ? ziniaoShopSwitchService.emptyMatchResult() : storeMatchByShopName.computeIfAbsent(normalizedShopName, - ignored -> ziniaoShopSwitchService.matchStoreByNameAcrossStaff(item.getShopName(), null)); + ignored -> ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName())); + item.setMatchStatus(matchResult.getMatchStatus()); + item.setMatchMessage(matchResult.getMatchMessage()); item.setMatched(matchResult.isMatched()); if (matchResult.isMatched()) { item.setShopId(matchResult.getShopId()); item.setPlatform(matchResult.getPlatform()); item.setOpenStoreUrl(matchResult.getOpenStoreUrl()); - } else { - throw new BusinessException("未匹配到紫鸟店铺,跳过处理"); } } catch (BusinessException ex) { String message = ex.getMessage(); @@ -155,10 +156,12 @@ public class DeleteBrandRunService { cacheDto.setPreviewRows(parsed.previewRows()); parsedPayloadByFileIdentity.put(fileIdentity, cacheDto); processableFileCount++; + parsedSuccessCount++; + } else { + parsedFailedCount++; } item.setSuccess(true); - parsedSuccessCount++; FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); @@ -195,7 +198,7 @@ public class DeleteBrandRunService { items.add(item); } - task.setSuccessFileCount(0); + task.setSuccessFileCount(parsedSuccessCount); task.setFailedFileCount(parsedFailedCount); task.setResultJson(JSONUtil.toJsonStr(items)); task.setUpdatedAt(LocalDateTime.now()); @@ -278,9 +281,13 @@ public class DeleteBrandRunService { } if (entity.getSourceFilename().equals(candidate.getSourceFilename())) { item.setMatched(candidate.isMatched()); + item.setMatchStatus(candidate.getMatchStatus()); + item.setMatchMessage(candidate.getMatchMessage()); // 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功 if (item.isSuccess()) { item.setMatched(true); + item.setMatchStatus("MATCHED"); + item.setMatchMessage(null); } item.setShopId(candidate.getShopId()); @@ -535,6 +542,7 @@ public class DeleteBrandRunService { } private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map progress) { + task = refreshIndexedMatchesIfNeeded(task); DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo(); taskVo.setId(task.getId()); taskVo.setTaskNo(task.getTaskNo()); @@ -560,9 +568,129 @@ public class DeleteBrandRunService { DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo(); detail.setTask(taskVo); detail.setLine_progress(progressVo); + detail.setItems(resolveTaskResultItems(task)); return detail; } + private List resolveTaskResultItems(FileTaskEntity task) { + if (task == null || task.getResultJson() == null || task.getResultJson().isBlank()) { + return List.of(); + } + try { + List items = objectMapper.readValue(task.getResultJson(), new TypeReference>() { + }); + if (items == null || items.isEmpty()) { + return List.of(); + } + return items.stream().peek(item -> { + if (item == null) { + return; + } + item.setTaskId(task.getId()); + item.setTaskStatus(task.getStatus()); + if ("SUCCESS".equals(task.getStatus()) && item.isSuccess()) { + item.setMatched(true); + item.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED); + item.setMatchMessage(null); + } + }).toList(); + } catch (Exception ignored) { + return List.of(); + } + } + + private FileTaskEntity refreshIndexedMatchesIfNeeded(FileTaskEntity task) { + if (task == null || task.getResultJson() == null || task.getResultJson().isBlank()) { + return task; + } + if (!"RUNNING".equals(task.getStatus()) && !"FAILED".equals(task.getStatus())) { + return task; + } + try { + List items = objectMapper.readValue(task.getResultJson(), new TypeReference>() { + }); + if (items == null || items.isEmpty()) { + return task; + } + boolean changed = false; + int matchedCount = 0; + int unmatchedCount = 0; + for (DeleteBrandResultItemVo item : items) { + if (item == null) { + continue; + } + String currentMatchStatus = item.getMatchStatus(); + if (currentMatchStatus == null || ZiniaoShopIndexService.MATCH_STATUS_MATCHED.equals(currentMatchStatus)) { + if (item.isMatched()) { + matchedCount++; + } else { + unmatchedCount++; + } + continue; + } + if (!ZiniaoShopIndexService.MATCH_STATUS_PENDING.equals(currentMatchStatus) + && !ZiniaoShopIndexService.MATCH_STATUS_STALE.equals(currentMatchStatus)) { + unmatchedCount++; + continue; + } + String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + if (normalizedShopName.isBlank()) { + unmatchedCount++; + continue; + } + ZiniaoShopMatchResultVo refreshed = ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName()); + if (refreshed != null) { + String oldStatus = item.getMatchStatus(); + boolean oldMatched = item.isMatched(); + String oldShopId = item.getShopId(); + String oldPlatform = item.getPlatform(); + String oldOpenStoreUrl = item.getOpenStoreUrl(); + String oldMessage = item.getMatchMessage(); + + item.setMatched(refreshed.isMatched()); + item.setMatchStatus(refreshed.getMatchStatus()); + item.setMatchMessage(refreshed.getMatchMessage()); + item.setShopId(refreshed.getShopId()); + item.setPlatform(refreshed.getPlatform()); + item.setOpenStoreUrl(refreshed.getOpenStoreUrl()); + + if (!java.util.Objects.equals(oldStatus, item.getMatchStatus()) + || oldMatched != item.isMatched() + || !java.util.Objects.equals(oldShopId, item.getShopId()) + || !java.util.Objects.equals(oldPlatform, item.getPlatform()) + || !java.util.Objects.equals(oldOpenStoreUrl, item.getOpenStoreUrl()) + || !java.util.Objects.equals(oldMessage, item.getMatchMessage())) { + changed = true; + } + } + if (item.isMatched()) { + matchedCount++; + } else { + unmatchedCount++; + } + } + if (!changed) { + return task; + } + task.setResultJson(JSONUtil.toJsonStr(items)); + task.setSourceFileCount(matchedCount); + task.setFailedFileCount(unmatchedCount); + task.setUpdatedAt(LocalDateTime.now()); + if (matchedCount > 0 && "FAILED".equals(task.getStatus())) { + task.setStatus("RUNNING"); + task.setErrorMessage(null); + task.setFinishedAt(null); + } + fileTaskMapper.updateById(task); + if ("RUNNING".equals(task.getStatus())) { + deleteBrandTaskCacheService.saveTaskCache(task); + } + } catch (Exception ex) { + log.debug("refreshIndexedMatchesIfNeeded skipped for taskId={}, msg={}", task.getId(), ex.getMessage()); + } + return task; + } + private DeleteBrandLineProgressVo buildLineProgress(Long taskId) { return buildLineProgress(taskId, null); } @@ -926,7 +1054,9 @@ public class DeleteBrandRunService { item.setCountries(parsedFile.getCountries()); item.setPreviewRows(parsedFile.getPreviewRows()); item.setTruncated(false); - item.setSuccess(true); + item.setMatched(true); + item.setMatchStatus("MATCHED"); + item.setMatchMessage(null); finalItems.add(item); successCount++; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java index 36ae1b6..8724bec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -8,7 +8,9 @@ import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,9 +18,11 @@ import java.util.List; @Service @RequiredArgsConstructor +@Slf4j public class ShopKeyService { private final ShopKeyMapper shopKeyMapper; + private final ZiniaoShopIndexService ziniaoShopIndexService; public ShopKeyPageVo page(long page, long pageSize) { long safePage = Math.max(page, 1); @@ -46,6 +50,7 @@ public class ShopKeyService { entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); shopKeyMapper.insert(entity); + triggerShopIndexRefresh(); return toItemVo(getById(entity.getId())); } @@ -57,6 +62,7 @@ public class ShopKeyService { entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); shopKeyMapper.updateById(entity); + triggerShopIndexRefresh(); return toItemVo(getById(id)); } @@ -64,6 +70,7 @@ public class ShopKeyService { public void delete(Long id) { ShopKeyEntity entity = getById(id); shopKeyMapper.deleteById(entity.getId()); + triggerShopIndexRefresh(); } private ShopKeyEntity getById(Long id) { @@ -91,4 +98,12 @@ public class ShopKeyService { vo.setUpdatedAt(entity.getUpdatedAt()); return vo; } + + private void triggerShopIndexRefresh() { + try { + ziniaoShopIndexService.invalidateIndex(); + } catch (Exception ex) { + log.warn("[ziniao-index] refresh trigger failed after shop key change: {}", ex.getMessage()); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java index 5e0bd17..457c9e3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/controller/ZiniaoAuthController.java @@ -1,12 +1,14 @@ package com.nanri.aiimage.modules.ziniao.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexRefreshCursorDto; import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo; import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; public class ZiniaoAuthController { private final ZiniaoAuthService ziniaoAuthService; + private final ZiniaoShopIndexService ziniaoShopIndexService; @GetMapping("/session") @Operation(summary = "获取紫鸟会话状态", description = "自动获取或复用 appToken,并返回 companyId、当前员工 userId、脱敏 token 和当前店铺信息。") @@ -47,6 +50,12 @@ public class ZiniaoAuthController { return ApiResponse.success(ziniaoAuthService.listStaff()); } + @GetMapping("/index-refresh") + @Operation(summary = "获取紫鸟店铺索引刷新状态", description = "返回后台定时刷新紫鸟店铺索引的最近一次执行状态、时间和无效 userId 样本。") + public ApiResponse getIndexRefreshStatus() { + return ApiResponse.success(ziniaoShopIndexService.getRefreshCursor()); + } + @GetMapping("/shops") @Operation(summary = "获取员工可见店铺列表", description = "先通过 API Key 获取 companyId,再按员工 userId 查询该员工有权限的店铺列表,并缓存到当前 session。") @ApiResponses({ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java index 84c9f67..3eb4700 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java @@ -62,6 +62,24 @@ public class ZiniaoMemoryStoreService { return get(cacheType, cacheKey, type); } + public List listByType(String cacheType, Class valueType, int limit) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + int safeLimit = Math.max(limit, 1); + List entities = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper() + .eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType) + .orderByAsc(ZiniaoMemoryStoreEntity::getCacheKey) + .last("LIMIT " + safeLimit)); + if (entities.isEmpty()) { + return List.of(); + } + LocalDateTime now = LocalDateTime.now(); + return entities.stream() + .filter(entity -> entity.getExpiresAt() != null && entity.getExpiresAt().isAfter(now)) + .map(entity -> deserialize(entity.getPayloadJson(), valueType)) + .filter(Objects::nonNull) + .toList(); + } + @Transactional public void put(String cacheType, String cacheKey, Object payload, Duration ttl) { String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); @@ -103,6 +121,24 @@ public class ZiniaoMemoryStoreService { } } + @Transactional + public int deleteByType(String cacheType, int limit) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + int safeLimit = Math.max(limit, 1); + List entities = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper() + .eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType) + .orderByAsc(ZiniaoMemoryStoreEntity::getCacheKey) + .last("LIMIT " + safeLimit)); + if (entities.isEmpty()) { + return 0; + } + int deleted = 0; + for (ZiniaoMemoryStoreEntity entity : entities) { + deleted += ziniaoMemoryStoreMapper.deleteById(entity.getId()); + } + return deleted; + } + @Transactional public int deleteExpired(int limit) { int safeLimit = Math.max(limit, 1); @@ -129,6 +165,14 @@ public class ZiniaoMemoryStoreService { .last("LIMIT 1")); } + private T deserialize(String payloadJson, Class valueType) { + try { + return objectMapper.readValue(payloadJson, valueType); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟记忆存储失败"); + } + } + private boolean isExpired(ZiniaoMemoryStoreEntity entity) { return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now()); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexEntryDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexEntryDto.java new file mode 100644 index 0000000..6c5f0f0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexEntryDto.java @@ -0,0 +1,24 @@ +package com.nanri.aiimage.modules.ziniao.model.cache; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ZiniaoShopIndexEntryDto { + private String normalizedShopName; + private String shopId; + private String shopName; + private String platform; + private Long matchedUserId; + private Long companyId; + private String apiKeyHash; + private String status; + private String message; + private Integer candidateCount; + private List sampleShopIds = new ArrayList<>(); + private List sampleUserIds = new ArrayList<>(); + private Long lastSeenAt; + private Long lastRefreshedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexRefreshCursorDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexRefreshCursorDto.java new file mode 100644 index 0000000..b3d07d0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoShopIndexRefreshCursorDto.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.ziniao.model.cache; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ZiniaoShopIndexRefreshCursorDto { + private String scopeKey; + private String status; + private String message; + private Long lastStartedAt; + private Long lastFinishedAt; + private Long lastSuccessAt; + private Integer invalidUserCount; + private List sampleInvalidUserIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java index b88ca41..fb2568c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java @@ -27,4 +27,10 @@ public class ZiniaoShopMatchResultVo { @Schema(description = "打开店铺链接") private String openStoreUrl; + + @Schema(description = "匹配状态: MATCHED / PENDING / CONFLICT") + private String matchStatus; + + @Schema(description = "匹配说明") + private String matchMessage; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index 71f2694..2c8d6f8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -112,6 +112,64 @@ public class ZiniaoAuthService { return new StoreMatchResult(false, null, null, null, null, null); } + public String buildOpenStoreUrlByScope(String apiKey, Long companyId, Long userId, String shopId) { + ensureEnabled(); + if (apiKey == null || apiKey.isBlank()) { + throw new BusinessException("紫鸟 apiKey 未配置"); + } + if (companyId == null || companyId <= 0) { + throw new BusinessException("紫鸟 companyId 不合法"); + } + if (userId == null || userId <= 0) { + throw new BusinessException("紫鸟 userId 不合法"); + } + if (shopId == null || shopId.isBlank()) { + throw new BusinessException("店铺不存在"); + } + String loginToken = ziniaoClient.getUserLoginToken(apiKey, companyId, userId); + return buildOpenStoreUrl(shopId, userId, loginToken); + } + + public Long resolveCompanyIdForIndex(String apiKey) { + return resolveCompanyId(apiKey); + } + + public List getOrLoadStaffForIndex(String apiKey, Long companyId) { + return getOrLoadStaff(apiKey, companyId); + } + + public List getOrLoadUserStoresForIndex(String apiKey, Long companyId, Long userId) { + return getOrLoadUserStores(apiKey, companyId, userId); + } + + public boolean isInvalidUserStoresError(BusinessException ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return false; + } + return message.contains("code=40004") + || message.contains("userId存在无效的参数值"); + } + + public void evictInvalidUserForIndex(String apiKey, Long companyId, Long userId) { + if (apiKey == null || apiKey.isBlank() || companyId == null || companyId <= 0 || userId == null || userId <= 0) { + return; + } + String staffCacheKey = buildApiKeyHash(apiKey) + ":" + companyId; + ziniaoMemoryStoreService.getList(CACHE_TYPE_STAFF_LIST, staffCacheKey, ZiniaoStaffItemVo.class) + .ifPresent(staff -> { + List filtered = staff.stream() + .filter(item -> item != null && !userId.equals(item.getUserId())) + .toList(); + if (filtered.isEmpty()) { + ziniaoMemoryStoreService.delete(CACHE_TYPE_STAFF_LIST, staffCacheKey); + } else { + ziniaoMemoryStoreService.put(CACHE_TYPE_STAFF_LIST, staffCacheKey, filtered, STAFF_LIST_CACHE_TTL); + } + }); + ziniaoMemoryStoreService.delete(CACHE_TYPE_USER_STORES, buildApiKeyHash(apiKey) + ":" + companyId + ":" + userId); + } + private boolean isSkippableUserStoresError(BusinessException ex) { String message = ex == null ? null : ex.getMessage(); if (message == null || message.isBlank()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexRefreshService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexRefreshService.java new file mode 100644 index 0000000..8d3a90b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexRefreshService.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ZiniaoShopIndexRefreshService { + + private final ZiniaoShopIndexService ziniaoShopIndexService; + + @Scheduled(cron = "${aiimage.ziniao.shop-index-refresh-cron:0 */10 * * * *}") + public void refreshShopIndex() { + try { + ziniaoShopIndexService.refreshShopIndex(); + } catch (Exception ex) { + log.warn("[ziniao-index] refresh failed: {}", ex.getMessage()); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java new file mode 100644 index 0000000..fabbf27 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -0,0 +1,414 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.ZiniaoProperties; +import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexEntryDto; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexRefreshCursorDto; +import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ZiniaoShopIndexService { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_STALE = "STALE"; + public static final String STATUS_CONFLICT = "CONFLICT"; + public static final String STATUS_PENDING = "PENDING"; + public static final String MATCH_STATUS_MATCHED = "MATCHED"; + public static final String MATCH_STATUS_PENDING = "PENDING"; + public static final String MATCH_STATUS_CONFLICT = "CONFLICT"; + public static final String MATCH_STATUS_STALE = "INDEX_STALE"; + + private static final String CACHE_TYPE_SHOP_INDEX_ENTRY = "SHOP_INDEX_ENTRY"; + private static final String CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT = "SHOP_INDEX_SCOPE_SNAPSHOT"; + private static final String CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR = "SHOP_INDEX_REFRESH_CURSOR"; + private static final Duration DEFAULT_ENTRY_TTL = Duration.ofHours(12); + private static final Duration DEFAULT_CURSOR_TTL = Duration.ofHours(24); + private static final int DEFAULT_REFRESH_BATCH_SIZE = 100; + private static final int DEFAULT_LIST_BY_TYPE_LIMIT = 5000; + + private final ZiniaoMemoryStoreService ziniaoMemoryStoreService; + private final ZiniaoApiKeyProvider ziniaoApiKeyProvider; + private final ZiniaoAuthService ziniaoAuthService; + private final ZiniaoProperties ziniaoProperties; + + public ZiniaoShopIndexRefreshCursorDto getRefreshCursor() { + ZiniaoShopIndexRefreshCursorDto cursor = ziniaoMemoryStoreService + .get(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global", ZiniaoShopIndexRefreshCursorDto.class) + .orElse(null); + if (cursor == null) { + cursor = new ZiniaoShopIndexRefreshCursorDto(); + cursor.setScopeKey("global"); + cursor.setStatus("IDLE"); + cursor.setMessage("索引刷新任务尚未执行"); + } + return cursor; + } + + public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName) { + String normalizedShopName = normalizeShopName(targetShopName); + if (normalizedShopName.isBlank()) { + return pendingResult("店铺名为空,无法匹配索引"); + } + + ZiniaoShopIndexEntryDto entry = ziniaoMemoryStoreService + .get(CACHE_TYPE_SHOP_INDEX_ENTRY, normalizedShopName, ZiniaoShopIndexEntryDto.class) + .orElse(null); + if (entry == null) { + return pendingResult("店铺索引未命中,请等待后台刷新"); + } + if (STATUS_CONFLICT.equals(entry.getStatus())) { + return conflictResult(entry.getMessage() == null || entry.getMessage().isBlank() + ? "存在多个同名店铺,请人工确认" + : entry.getMessage()); + } + if (!STATUS_ACTIVE.equals(entry.getStatus())) { + return staleOrPendingResult(entry.getMessage() == null || entry.getMessage().isBlank() + ? "店铺索引暂不可用,请等待后台刷新" + : entry.getMessage(), entry); + } + if (entry.getShopId() == null || entry.getShopId().isBlank() || entry.getMatchedUserId() == null || entry.getMatchedUserId() <= 0) { + return pendingResult("店铺索引信息不完整,请等待后台刷新"); + } + + String apiKey = findApiKeyByHash(entry.getApiKeyHash()); + if (apiKey == null) { + return pendingResult("店铺索引所依赖的紫鸟 key 已失效,请等待后台刷新"); + } + + Long companyId = entry.getCompanyId(); + if (companyId == null || companyId <= 0) { + return pendingResult("店铺索引缺少 companyId,请等待后台刷新"); + } + + String openStoreUrl = ziniaoAuthService.buildOpenStoreUrlByScope(apiKey, companyId, entry.getMatchedUserId(), entry.getShopId()); + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(true); + vo.setShopId(entry.getShopId()); + vo.setShopName(entry.getShopName()); + vo.setPlatform(entry.getPlatform()); + vo.setMatchedUserId(entry.getMatchedUserId()); + vo.setOpenStoreUrl(openStoreUrl); + vo.setMatchStatus(isFresh(entry) ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE); + vo.setMatchMessage(isFresh(entry) ? null : "店铺索引已过保鲜期,请等待后台刷新"); + return vo; + } + + public void refreshShopIndex() { + long now = Instant.now().toEpochMilli(); + ZiniaoShopIndexRefreshCursorDto cursor = new ZiniaoShopIndexRefreshCursorDto(); + cursor.setScopeKey("global"); + cursor.setStatus("RUNNING"); + cursor.setLastStartedAt(now); + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global", cursor, DEFAULT_CURSOR_TTL); + + Map> grouped = new LinkedHashMap<>(); + Set allInvalidUserIds = new LinkedHashSet<>(); + int refreshBatchSize = resolveRefreshBatchSize(); + try { + List apiKeys = ziniaoApiKeyProvider.listApiKeys(); + if (refreshBatchSize > 0 && apiKeys.size() > refreshBatchSize) { + apiKeys = apiKeys.subList(0, refreshBatchSize); + } + for (String apiKey : apiKeys) { + Long companyId; + try { + companyId = ziniaoAuthService.resolveCompanyIdForIndex(apiKey); + } catch (BusinessException ex) { + log.warn("[ziniao-index] skip apiKey while resolving companyId, msg={}", ex.getMessage()); + continue; + } + + Set invalidUserIds = new LinkedHashSet<>(); + List staff = ziniaoAuthService.getOrLoadStaffForIndex(apiKey, companyId); + for (Long userId : buildUserIds(staff)) { + if (invalidUserIds.contains(userId)) { + continue; + } + List stores; + try { + stores = ziniaoAuthService.getOrLoadUserStoresForIndex(apiKey, companyId, userId); + } catch (BusinessException ex) { + if (ziniaoAuthService.isInvalidUserStoresError(ex)) { + invalidUserIds.add(userId); + allInvalidUserIds.add(userId); + ziniaoAuthService.evictInvalidUserForIndex(apiKey, companyId, userId); + } + log.warn("[ziniao-index] skip user stores, companyId={}, userId={}, msg={}", companyId, userId, ex.getMessage()); + continue; + } + ziniaoMemoryStoreService.put( + CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT, + buildScopeSnapshotKey(apiKey, companyId, userId), + stores, + resolveEntryTtl() + ); + for (ZiniaoShopCacheDto store : stores) { + String normalizedShopName = normalizeShopName(store == null ? null : store.getShopName()); + if (normalizedShopName.isBlank()) { + continue; + } + ZiniaoShopIndexEntryDto entry = new ZiniaoShopIndexEntryDto(); + entry.setNormalizedShopName(normalizedShopName); + entry.setShopId(store.getShopId()); + entry.setShopName(store.getShopName()); + entry.setPlatform(store.getPlatform()); + entry.setMatchedUserId(userId); + entry.setCompanyId(companyId); + entry.setApiKeyHash(buildApiKeyHash(apiKey)); + entry.setStatus(STATUS_ACTIVE); + entry.setLastSeenAt(now); + entry.setLastRefreshedAt(now); + List candidates = grouped.computeIfAbsent(normalizedShopName, ignored -> new ArrayList<>()); + if (candidates.stream().noneMatch(existing -> sameCandidate(existing, entry))) { + candidates.add(entry); + } + } + } + if (!invalidUserIds.isEmpty()) { + for (Long invalidUserId : invalidUserIds) { + ziniaoMemoryStoreService.delete( + CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT, + buildScopeSnapshotKey(apiKey, companyId, invalidUserId) + ); + } + } + } + + for (Map.Entry> groupedEntry : grouped.entrySet()) { + String normalizedShopName = groupedEntry.getKey(); + List candidates = groupedEntry.getValue(); + ZiniaoShopIndexEntryDto entryToStore = candidates.get(0); + if (candidates.size() > 1) { + entryToStore = buildConflictEntry(normalizedShopName, candidates, now); + } + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_INDEX_ENTRY, normalizedShopName, entryToStore, resolveEntryTtl()); + } + markMissingEntriesAsStale(grouped.keySet(), now); + + cursor.setStatus("SUCCESS"); + cursor.setMessage(allInvalidUserIds.isEmpty() + ? null + : "本轮刷新已跳过无效 userId 数: " + allInvalidUserIds.size()); + cursor.setInvalidUserCount(allInvalidUserIds.size()); + cursor.setSampleInvalidUserIds(allInvalidUserIds.stream().limit(20).toList()); + cursor.setLastFinishedAt(now); + cursor.setLastSuccessAt(now); + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global", cursor, DEFAULT_CURSOR_TTL); + } catch (Exception ex) { + cursor.setStatus("FAILED"); + cursor.setMessage(ex.getMessage()); + cursor.setLastFinishedAt(now); + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global", cursor, DEFAULT_CURSOR_TTL); + if (ex instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException("刷新紫鸟店铺索引失败"); + } + } + + public void invalidateIndex() { + ziniaoMemoryStoreService.delete(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global"); + } + + public String normalizeShopName(String value) { + if (value == null) { + return ""; + } + return value.replace("\u3000", " ").trim(); + } + + private ZiniaoShopMatchResultVo pendingResult(String message) { + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(false); + vo.setMatchStatus(MATCH_STATUS_PENDING); + vo.setMatchMessage(message); + return vo; + } + + private ZiniaoShopMatchResultVo conflictResult(String message) { + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(false); + vo.setMatchStatus(MATCH_STATUS_CONFLICT); + vo.setMatchMessage(message); + return vo; + } + + private ZiniaoShopIndexEntryDto buildConflictEntry(String normalizedShopName, List candidates, long now) { + ZiniaoShopIndexEntryDto representative = candidates == null || candidates.isEmpty() ? null : candidates.get(0); + ZiniaoShopIndexEntryDto entry = new ZiniaoShopIndexEntryDto(); + entry.setNormalizedShopName(normalizedShopName); + entry.setStatus(STATUS_CONFLICT); + entry.setShopName(representative == null ? normalizedShopName : representative.getShopName()); + entry.setCompanyId(representative == null ? null : representative.getCompanyId()); + entry.setApiKeyHash(representative == null ? null : representative.getApiKeyHash()); + entry.setCandidateCount(candidates == null ? 0 : candidates.size()); + entry.setMessage("存在多个同名店铺,请人工确认(候选数: " + entry.getCandidateCount() + ")"); + entry.setLastSeenAt(now); + entry.setLastRefreshedAt(now); + if (candidates != null) { + for (ZiniaoShopIndexEntryDto candidate : candidates) { + if (candidate == null) { + continue; + } + if (candidate.getShopId() != null && !candidate.getShopId().isBlank() && !entry.getSampleShopIds().contains(candidate.getShopId())) { + entry.getSampleShopIds().add(candidate.getShopId()); + } + if (candidate.getMatchedUserId() != null && candidate.getMatchedUserId() > 0 && !entry.getSampleUserIds().contains(candidate.getMatchedUserId())) { + entry.getSampleUserIds().add(candidate.getMatchedUserId()); + } + } + } + return entry; + } + + private ZiniaoShopMatchResultVo staleOrPendingResult(String defaultMessage, ZiniaoShopIndexEntryDto entry) { + if (entry != null && STATUS_STALE.equals(entry.getStatus())) { + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(false); + vo.setMatchStatus(MATCH_STATUS_STALE); + vo.setMatchMessage(defaultMessage); + return vo; + } + return pendingResult(defaultMessage); + } + + private void markMissingEntriesAsStale(Set activeNames, long now) { + List existingEntries = ziniaoMemoryStoreService.listByType( + CACHE_TYPE_SHOP_INDEX_ENTRY, + ZiniaoShopIndexEntryDto.class, + DEFAULT_LIST_BY_TYPE_LIMIT + ); + if (existingEntries.isEmpty()) { + return; + } + for (ZiniaoShopIndexEntryDto existingEntry : existingEntries) { + if (existingEntry == null || existingEntry.getNormalizedShopName() == null || existingEntry.getNormalizedShopName().isBlank()) { + continue; + } + if (activeNames.contains(existingEntry.getNormalizedShopName())) { + continue; + } + if (STATUS_CONFLICT.equals(existingEntry.getStatus()) + && (existingEntry.getCandidateCount() == null || existingEntry.getCandidateCount() <= 0) + && (existingEntry.getShopName() == null || existingEntry.getShopName().isBlank()) + && (existingEntry.getApiKeyHash() == null || existingEntry.getApiKeyHash().isBlank()) + && (existingEntry.getCompanyId() == null || existingEntry.getCompanyId() <= 0)) { + ziniaoMemoryStoreService.delete(CACHE_TYPE_SHOP_INDEX_ENTRY, existingEntry.getNormalizedShopName()); + continue; + } + existingEntry.setStatus(STATUS_STALE); + existingEntry.setMessage("店铺索引已过期,请等待后台刷新"); + existingEntry.setLastRefreshedAt(now); + ziniaoMemoryStoreService.put( + CACHE_TYPE_SHOP_INDEX_ENTRY, + existingEntry.getNormalizedShopName(), + existingEntry, + resolveEntryTtl() + ); + } + } + + private boolean isFresh(ZiniaoShopIndexEntryDto entry) { + if (entry == null || entry.getLastRefreshedAt() == null || entry.getLastRefreshedAt() <= 0) { + return false; + } + Integer freshMinutes = ziniaoProperties.getShopIndexFreshMinutes(); + if (freshMinutes == null || freshMinutes <= 0) { + return true; + } + long freshWindowMillis = Duration.ofMinutes(freshMinutes).toMillis(); + return Instant.now().toEpochMilli() - entry.getLastRefreshedAt() <= freshWindowMillis; + } + + private Duration resolveEntryTtl() { + Integer ttlHours = ziniaoProperties.getShopIndexEntryTtlHours(); + if (ttlHours == null || ttlHours <= 0) { + return DEFAULT_ENTRY_TTL; + } + return Duration.ofHours(ttlHours); + } + + private int resolveRefreshBatchSize() { + Integer batchSize = ziniaoProperties.getShopIndexRefreshBatchSize(); + if (batchSize == null || batchSize <= 0) { + return DEFAULT_REFRESH_BATCH_SIZE; + } + return batchSize; + } + + private List buildUserIds(List staff) { + List userIds = new ArrayList<>(); + for (ZiniaoStaffItemVo item : staff) { + if (item != null && item.getUserId() != null && item.getUserId() > 0 && !userIds.contains(item.getUserId())) { + userIds.add(item.getUserId()); + } + } + return userIds; + } + + private boolean sameCandidate(ZiniaoShopIndexEntryDto left, ZiniaoShopIndexEntryDto right) { + if (left == null || right == null) { + return false; + } + if (left.getShopId() != null && right.getShopId() != null) { + return Objects.equals(left.getShopId(), right.getShopId()) + && Objects.equals(left.getMatchedUserId(), right.getMatchedUserId()) + && Objects.equals(left.getCompanyId(), right.getCompanyId()) + && Objects.equals(left.getApiKeyHash(), right.getApiKeyHash()); + } + return Objects.equals(left.getShopName(), right.getShopName()) + && Objects.equals(left.getMatchedUserId(), right.getMatchedUserId()) + && Objects.equals(left.getCompanyId(), right.getCompanyId()) + && Objects.equals(left.getApiKeyHash(), right.getApiKeyHash()); + } + + private String buildScopeSnapshotKey(String apiKey, Long companyId, Long userId) { + return buildApiKeyHash(apiKey) + ":" + companyId + ":" + userId; + } + + private String findApiKeyByHash(String apiKeyHash) { + if (apiKeyHash == null || apiKeyHash.isBlank()) { + return null; + } + for (String apiKey : ziniaoApiKeyProvider.listApiKeys()) { + if (apiKeyHash.equals(buildApiKeyHash(apiKey))) { + return apiKey; + } + } + return null; + } + + private String buildApiKeyHash(String apiKey) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new BusinessException("生成紫鸟 apiKey 缓存键失败"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java index 3f9b6d6..a63fdb9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; public class ZiniaoShopSwitchService { private final ZiniaoAuthService ziniaoAuthService; + private final ZiniaoShopIndexService ziniaoShopIndexService; public ZiniaoShopMatchResultVo matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { String normalizedShopName = normalizeShopName(targetShopName); @@ -18,18 +19,27 @@ public class ZiniaoShopSwitchService { } ZiniaoAuthService.StoreMatchResult result = ziniaoAuthService.matchStoreByNameAcrossStaff(normalizedShopName, preferUserId); - return new ZiniaoShopMatchResultVo( - result.matched(), - result.shopId(), - result.shopName(), - result.platform(), - result.matchedUserId(), - result.openStoreUrl() - ); + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(result.matched()); + vo.setShopId(result.shopId()); + vo.setShopName(result.shopName()); + vo.setPlatform(result.platform()); + vo.setMatchedUserId(result.matchedUserId()); + vo.setOpenStoreUrl(result.openStoreUrl()); + vo.setMatchStatus(result.matched() ? ZiniaoShopIndexService.MATCH_STATUS_MATCHED : ZiniaoShopIndexService.MATCH_STATUS_PENDING); + return vo; + } + + public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName) { + return ziniaoShopIndexService.findIndexedStoreByName(targetShopName); } public ZiniaoShopMatchResultVo emptyMatchResult() { - return new ZiniaoShopMatchResultVo(false, null, null, null, null, null); + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(false); + vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_PENDING); + vo.setMatchMessage("店铺名为空,无法匹配索引"); + return vo; } public String normalizeShopName(String value) { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 7ce15a9..858a1a9 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -108,5 +108,9 @@ aiimage: shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30} connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5} read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15} - key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:50} - key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:15} + key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:100} + key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:30} + shop-index-refresh-cron: ${AIIMAGE_ZINIAO_SHOP_INDEX_REFRESH_CRON:0 */10 * * * *} + shop-index-fresh-minutes: ${AIIMAGE_ZINIAO_SHOP_INDEX_FRESH_MINUTES:20} + shop-index-entry-ttl-hours: ${AIIMAGE_ZINIAO_SHOP_INDEX_ENTRY_TTL_HOURS:12} + shop-index-refresh-batch-size: ${AIIMAGE_ZINIAO_SHOP_INDEX_REFRESH_BATCH_SIZE:100} diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 1519d57..535fea2 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -105,7 +105,7 @@
{{ item.sourceFilename || '-' }}
店铺名:{{ item.shopName || '-' }}
-
匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}
+
匹配结果:{{ formatMatchResult(item) }}
平台:{{ item.platform }}
队列状态:{{ getQueueStatus(item) }}
国家数:{{ item.countryCount @@ -158,7 +158,7 @@
{{ item.sourceFilename || '-' }}
店铺名:{{ item.shopName || '-' }}
-
匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}
+
匹配结果:{{ formatMatchResult(item) }}
平台:{{ item.platform }}
国家数:{{ item.countryCount }}
@@ -281,8 +281,7 @@ function loadSessionTasksFromStorage() { } function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) { - // 只将解析成功且匹配成功的任务放入当前队列中,失败的将留在历史记录里 - const validItems = items.filter(i => i.matched !== false && i.success !== false) + const validItems = items.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) if (validItems.length === 0) return const newTask: StoredCurrentTask = { @@ -326,6 +325,9 @@ function getTaskStatus(taskId?: number) { function getDisplayError(item: DeleteBrandResultItem) { if (item.error) return item.error + if (item.matchStatus && item.matchStatus !== 'MATCHED') { + return item.matchMessage || '' + } const taskId = item.taskId if (!taskId) return '' return taskDetails.value[taskId]?.task?.errorMessage || '' @@ -339,18 +341,37 @@ function shouldShowProgress(item: DeleteBrandResultItem) { function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) { return items.map((item) => { - // 只有在既没成功,又显式标记为未匹配时,才做错误兜底 - if (!item.success && item.matched === false) { - return { - ...item, - error: item.error || '未匹配到紫鸟店铺', - openStoreUrl: undefined, - } + if (item.matchStatus === 'MATCHED') { + return item + } + return { + ...item, + matched: false, + openStoreUrl: undefined, + error: item.error || undefined, } - return item }) } +function formatMatchResult(item: DeleteBrandResultItem) { + if (item.matchStatus === 'MATCHED') { + return `已匹配 ${item.shopId || ''}`.trim() + } + if (item.matchMessage) { + return item.matchMessage + } + if (item.matchStatus === 'CONFLICT') { + return '存在多个同名店铺,请人工确认' + } + if (item.matchStatus === 'PENDING' || item.matchStatus === 'INDEX_STALE') { + return '店铺索引暂未就绪' + } + if (item.matchStatus) { + return item.matchStatus + } + return item.matched ? `已匹配 ${item.shopId || ''}`.trim() : '待匹配' +} + function mergeVisibleItems() { resultItems.value = [...currentSectionItems.value, ...historySectionItems.value] } @@ -369,10 +390,6 @@ function updateSummary(items: DeleteBrandResultItem[]) { } } -function getItemTaskId(item: DeleteBrandResultItem) { - return item.taskId || 0 -} - function formatProgress(taskId: number) { if (!taskId) return '' const detail = taskDetails.value[taskId] @@ -410,11 +427,6 @@ function formatProgressPercent(taskId: number) { return status === 'SUCCESS' ? 100 : 0 } -function isTaskRunning(taskId: number) { - const status = taskDetails.value[taskId]?.task?.status - return status === 'RUNNING' -} - function isTaskTerminal(taskId: number) { const status = taskDetails.value[taskId]?.task?.status return status === 'SUCCESS' || status === 'FAILED' @@ -477,18 +489,58 @@ function canDownloadTaskResult(item: DeleteBrandResultItem) { return status === 'SUCCESS' } +function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) { + const taskId = detail?.task?.id + if (!taskId || !detail?.items?.length) return false + const sessionTask = sessionTasks.value.find(t => t.taskId === taskId) + if (!sessionTask) return false + + let changed = false + const mergedItems = sessionTask.items.map((item) => { + const latest = detail.items?.find((candidate) => candidate.resultId === item.resultId || candidate.sourceFilename === item.sourceFilename) + if (!latest) return item + const merged = { + ...item, + ...latest, + success: item.success ?? latest.success, + } + if (JSON.stringify(merged) !== JSON.stringify(item)) { + changed = true + } + return merged + }) + + const validItems = mergedItems.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) + if (JSON.stringify(validItems) !== JSON.stringify(sessionTask.items)) { + sessionTask.items = validItems + changed = true + } + + if (changed && typeof window !== 'undefined') { + window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value)) + } + return changed +} + async function refreshTaskDetails(taskIds?: number[]) { const ids = taskIds || getPollingTaskIds() if (!ids.length) return try { const batch = await getDeleteBrandTaskDetails(ids) + let changed = false for (const detail of batch.items || []) { const id = detail?.task?.id if (typeof id === 'number' && id > 0) { taskDetails.value[id] = detail + if (mergeTaskDetailItemsIntoSession(detail)) { + changed = true + } } } + if (changed) { + syncResultState() + } } catch { // ignore polling failures } @@ -649,7 +701,8 @@ async function submitRun() { })), }) const normalizedItems = normalizeDeleteBrandItems(result.items || []) - const hasUnmatched = normalizedItems.some((item) => !item.matched) + const hasMatchedItems = normalizedItems.some((item) => item.matchStatus === 'MATCHED') + const hasPendingItems = normalizedItems.some((item) => item.matchStatus && item.matchStatus !== 'MATCHED') queuePushResult.value = '解析完成,等待手动点击打开紫鸟进行单任务处理' queuePayloadText.value = '' @@ -659,10 +712,12 @@ async function submitRun() { } syncResultState() - if (hasUnmatched) { - ElMessage.error('存在未匹配到紫鸟店铺的文件,请检查报错内容。这些文件已自动归档至历史记录。') - } else { + if (hasPendingItems) { + ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。') + } else if (hasMatchedItems) { ElMessage.success('删除品牌解析完成,请手动推送') + } else { + ElMessage.warning('当前没有可推送的已匹配文件,请先等待店铺索引刷新。') } // 更新历史记录,以展示那些未进入队列的失败项 @@ -783,10 +838,7 @@ function findNextAutoRunnableItem() { } async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) { - if (isPythonQueueBusy()) { - if (!options?.auto) { - ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!') - } + if (options?.auto && isPythonQueueBusy()) { return false } @@ -816,7 +868,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean } const pushResult = await api.enqueue_json(payload) let qpr = '' if (pushResult?.success) { - qpr = `已单独推送文件 ${item.sourceFilename},当前队列长度:${pushResult.queue_size ?? '-'}` + qpr = `已推送文件 ${item.sourceFilename},当前队列长度:${pushResult.queue_size ?? '-'}` const sessionItem = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename) if (sessionItem) { @@ -874,10 +926,6 @@ async function triggerItemRun(item: DeleteBrandResultItem) { } } -function openShop(url: string) { - window.open(url, '_blank') -} - onMounted(() => { loadSessionTasksFromStorage() const taskIds = getPollingTaskIds() diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 6e8fbd1..3f93ae2 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -211,12 +211,16 @@ export interface DeleteBrandTaskBatchVo { missingTaskIds: number[] } +export type DeleteBrandMatchStatus = 'MATCHED' | 'PENDING' | 'CONFLICT' | 'INDEX_STALE' + export interface DeleteBrandResultItem { resultId?: number fileKey?: string sourceFilename: string shopName?: string matched?: boolean + matchStatus?: DeleteBrandMatchStatus + matchMessage?: string shopId?: string platform?: string openStoreUrl?: string