紫鸟查询店铺更新

This commit is contained in:
super
2026-04-02 01:15:50 +08:00
parent df606a1087
commit 855f1affd9
25 changed files with 891 additions and 38992 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -40,4 +40,25 @@ public class ZiniaoProperties {
* null/空/<=0 表示不限制(不推荐)。 * null/空/<=0 表示不限制(不推荐)。
*/ */
private Integer keyScanMaxSeconds; private Integer keyScanMaxSeconds;
/**
* 店铺索引后台刷新 cron。
*/
private String shopIndexRefreshCron;
/**
* 店铺索引条目保鲜分钟数。
*/
private Integer shopIndexFreshMinutes;
/**
* 店铺索引条目 TTL 小时数。
*/
private Integer shopIndexEntryTtlHours;
/**
* 每次索引刷新最多扫描多少个 apiKey
* null/空/<=0 表示不限制(不推荐)。
*/
private Integer shopIndexRefreshBatchSize;
} }

View File

@@ -33,6 +33,12 @@ public class DeleteBrandResultItemVo {
@Schema(description = "紫鸟打开链接") @Schema(description = "紫鸟打开链接")
private String openStoreUrl; private String openStoreUrl;
@Schema(description = "匹配状态: MATCHED / PENDING / CONFLICT")
private String matchStatus;
@Schema(description = "匹配说明")
private String matchMessage;
@Schema(description = "总行数") @Schema(description = "总行数")
private Integer totalRows; private Integer totalRows;

View File

@@ -3,6 +3,9 @@ package com.nanri.aiimage.modules.deletebrand.model.vo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@Schema(description = "删除品牌任务详情响应") @Schema(description = "删除品牌任务详情响应")
public class DeleteBrandTaskDetailVo { public class DeleteBrandTaskDetailVo {
@@ -11,4 +14,7 @@ public class DeleteBrandTaskDetailVo {
@Schema(description = "实时行级进度信息,来自 Redis") @Schema(description = "实时行级进度信息,来自 Redis")
private DeleteBrandLineProgressVo line_progress; private DeleteBrandLineProgressVo line_progress;
@Schema(description = "任务结果项快照")
private List<DeleteBrandResultItemVo> items = new ArrayList<>();
} }

View File

@@ -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.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; 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 com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -125,14 +126,14 @@ public class DeleteBrandRunService {
ZiniaoShopMatchResultVo matchResult = normalizedShopName.isBlank() ZiniaoShopMatchResultVo matchResult = normalizedShopName.isBlank()
? ziniaoShopSwitchService.emptyMatchResult() ? ziniaoShopSwitchService.emptyMatchResult()
: storeMatchByShopName.computeIfAbsent(normalizedShopName, : 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()); item.setMatched(matchResult.isMatched());
if (matchResult.isMatched()) { if (matchResult.isMatched()) {
item.setShopId(matchResult.getShopId()); item.setShopId(matchResult.getShopId());
item.setPlatform(matchResult.getPlatform()); item.setPlatform(matchResult.getPlatform());
item.setOpenStoreUrl(matchResult.getOpenStoreUrl()); item.setOpenStoreUrl(matchResult.getOpenStoreUrl());
} else {
throw new BusinessException("未匹配到紫鸟店铺,跳过处理");
} }
} catch (BusinessException ex) { } catch (BusinessException ex) {
String message = ex.getMessage(); String message = ex.getMessage();
@@ -155,10 +156,12 @@ public class DeleteBrandRunService {
cacheDto.setPreviewRows(parsed.previewRows()); cacheDto.setPreviewRows(parsed.previewRows());
parsedPayloadByFileIdentity.put(fileIdentity, cacheDto); parsedPayloadByFileIdentity.put(fileIdentity, cacheDto);
processableFileCount++; processableFileCount++;
parsedSuccessCount++;
} else {
parsedFailedCount++;
} }
item.setSuccess(true); item.setSuccess(true);
parsedSuccessCount++;
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId()); resultEntity.setTaskId(task.getId());
@@ -195,7 +198,7 @@ public class DeleteBrandRunService {
items.add(item); items.add(item);
} }
task.setSuccessFileCount(0); task.setSuccessFileCount(parsedSuccessCount);
task.setFailedFileCount(parsedFailedCount); task.setFailedFileCount(parsedFailedCount);
task.setResultJson(JSONUtil.toJsonStr(items)); task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
@@ -278,9 +281,13 @@ public class DeleteBrandRunService {
} }
if (entity.getSourceFilename().equals(candidate.getSourceFilename())) { if (entity.getSourceFilename().equals(candidate.getSourceFilename())) {
item.setMatched(candidate.isMatched()); item.setMatched(candidate.isMatched());
item.setMatchStatus(candidate.getMatchStatus());
item.setMatchMessage(candidate.getMatchMessage());
// 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功 // 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功
if (item.isSuccess()) { if (item.isSuccess()) {
item.setMatched(true); item.setMatched(true);
item.setMatchStatus("MATCHED");
item.setMatchMessage(null);
} }
item.setShopId(candidate.getShopId()); item.setShopId(candidate.getShopId());
@@ -535,6 +542,7 @@ public class DeleteBrandRunService {
} }
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) { private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
task = refreshIndexedMatchesIfNeeded(task);
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo(); DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
taskVo.setId(task.getId()); taskVo.setId(task.getId());
taskVo.setTaskNo(task.getTaskNo()); taskVo.setTaskNo(task.getTaskNo());
@@ -560,9 +568,129 @@ public class DeleteBrandRunService {
DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo(); DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo();
detail.setTask(taskVo); detail.setTask(taskVo);
detail.setLine_progress(progressVo); detail.setLine_progress(progressVo);
detail.setItems(resolveTaskResultItems(task));
return detail; return detail;
} }
private List<DeleteBrandResultItemVo> resolveTaskResultItems(FileTaskEntity task) {
if (task == null || task.getResultJson() == null || task.getResultJson().isBlank()) {
return List.of();
}
try {
List<DeleteBrandResultItemVo> items = objectMapper.readValue(task.getResultJson(), new TypeReference<List<DeleteBrandResultItemVo>>() {
});
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<DeleteBrandResultItemVo> items = objectMapper.readValue(task.getResultJson(), new TypeReference<List<DeleteBrandResultItemVo>>() {
});
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) { private DeleteBrandLineProgressVo buildLineProgress(Long taskId) {
return buildLineProgress(taskId, null); return buildLineProgress(taskId, null);
} }
@@ -926,7 +1054,9 @@ public class DeleteBrandRunService {
item.setCountries(parsedFile.getCountries()); item.setCountries(parsedFile.getCountries());
item.setPreviewRows(parsedFile.getPreviewRows()); item.setPreviewRows(parsedFile.getPreviewRows());
item.setTruncated(false); item.setTruncated(false);
item.setSuccess(true); item.setMatched(true);
item.setMatchStatus("MATCHED");
item.setMatchMessage(null);
finalItems.add(item); finalItems.add(item);
successCount++; successCount++;
} }

View File

@@ -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.entity.ShopKeyEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -16,9 +18,11 @@ import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class ShopKeyService { public class ShopKeyService {
private final ShopKeyMapper shopKeyMapper; private final ShopKeyMapper shopKeyMapper;
private final ZiniaoShopIndexService ziniaoShopIndexService;
public ShopKeyPageVo page(long page, long pageSize) { public ShopKeyPageVo page(long page, long pageSize) {
long safePage = Math.max(page, 1); long safePage = Math.max(page, 1);
@@ -46,6 +50,7 @@ public class ShopKeyService {
entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken); entity.setZiniaoToken(ziniaoToken);
shopKeyMapper.insert(entity); shopKeyMapper.insert(entity);
triggerShopIndexRefresh();
return toItemVo(getById(entity.getId())); return toItemVo(getById(entity.getId()));
} }
@@ -57,6 +62,7 @@ public class ShopKeyService {
entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken); entity.setZiniaoToken(ziniaoToken);
shopKeyMapper.updateById(entity); shopKeyMapper.updateById(entity);
triggerShopIndexRefresh();
return toItemVo(getById(id)); return toItemVo(getById(id));
} }
@@ -64,6 +70,7 @@ public class ShopKeyService {
public void delete(Long id) { public void delete(Long id) {
ShopKeyEntity entity = getById(id); ShopKeyEntity entity = getById(id);
shopKeyMapper.deleteById(entity.getId()); shopKeyMapper.deleteById(entity.getId());
triggerShopIndexRefresh();
} }
private ShopKeyEntity getById(Long id) { private ShopKeyEntity getById(Long id) {
@@ -91,4 +98,12 @@ public class ShopKeyService {
vo.setUpdatedAt(entity.getUpdatedAt()); vo.setUpdatedAt(entity.getUpdatedAt());
return vo; return vo;
} }
private void triggerShopIndexRefresh() {
try {
ziniaoShopIndexService.invalidateIndex();
} catch (Exception ex) {
log.warn("[ziniao-index] refresh trigger failed after shop key change: {}", ex.getMessage());
}
}
} }

View File

@@ -1,12 +1,14 @@
package com.nanri.aiimage.modules.ziniao.controller; package com.nanri.aiimage.modules.ziniao.controller;
import com.nanri.aiimage.common.api.ApiResponse; 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.dto.ZiniaoOpenShopRequest;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo; 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.ZiniaoSessionVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService; 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.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
public class ZiniaoAuthController { public class ZiniaoAuthController {
private final ZiniaoAuthService ziniaoAuthService; private final ZiniaoAuthService ziniaoAuthService;
private final ZiniaoShopIndexService ziniaoShopIndexService;
@GetMapping("/session") @GetMapping("/session")
@Operation(summary = "获取紫鸟会话状态", description = "自动获取或复用 appToken并返回 companyId、当前员工 userId、脱敏 token 和当前店铺信息。") @Operation(summary = "获取紫鸟会话状态", description = "自动获取或复用 appToken并返回 companyId、当前员工 userId、脱敏 token 和当前店铺信息。")
@@ -47,6 +50,12 @@ public class ZiniaoAuthController {
return ApiResponse.success(ziniaoAuthService.listStaff()); return ApiResponse.success(ziniaoAuthService.listStaff());
} }
@GetMapping("/index-refresh")
@Operation(summary = "获取紫鸟店铺索引刷新状态", description = "返回后台定时刷新紫鸟店铺索引的最近一次执行状态、时间和无效 userId 样本。")
public ApiResponse<ZiniaoShopIndexRefreshCursorDto> getIndexRefreshStatus() {
return ApiResponse.success(ziniaoShopIndexService.getRefreshCursor());
}
@GetMapping("/shops") @GetMapping("/shops")
@Operation(summary = "获取员工可见店铺列表", description = "先通过 API Key 获取 companyId再按员工 userId 查询该员工有权限的店铺列表,并缓存到当前 session。") @Operation(summary = "获取员工可见店铺列表", description = "先通过 API Key 获取 companyId再按员工 userId 查询该员工有权限的店铺列表,并缓存到当前 session。")
@ApiResponses({ @ApiResponses({

View File

@@ -62,6 +62,24 @@ public class ZiniaoMemoryStoreService {
return get(cacheType, cacheKey, type); return get(cacheType, cacheKey, type);
} }
public <T> List<T> listByType(String cacheType, Class<T> valueType, int limit) {
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
int safeLimit = Math.max(limit, 1);
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper<ZiniaoMemoryStoreEntity>()
.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 @Transactional
public void put(String cacheType, String cacheKey, Object payload, Duration ttl) { public void put(String cacheType, String cacheKey, Object payload, Duration ttl) {
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); 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<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper<ZiniaoMemoryStoreEntity>()
.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 @Transactional
public int deleteExpired(int limit) { public int deleteExpired(int limit) {
int safeLimit = Math.max(limit, 1); int safeLimit = Math.max(limit, 1);
@@ -129,6 +165,14 @@ public class ZiniaoMemoryStoreService {
.last("LIMIT 1")); .last("LIMIT 1"));
} }
private <T> T deserialize(String payloadJson, Class<T> valueType) {
try {
return objectMapper.readValue(payloadJson, valueType);
} catch (Exception ex) {
throw new BusinessException("读取紫鸟记忆存储失败");
}
}
private boolean isExpired(ZiniaoMemoryStoreEntity entity) { private boolean isExpired(ZiniaoMemoryStoreEntity entity) {
return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now()); return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now());
} }

View File

@@ -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<String> sampleShopIds = new ArrayList<>();
private List<Long> sampleUserIds = new ArrayList<>();
private Long lastSeenAt;
private Long lastRefreshedAt;
}

View File

@@ -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<Long> sampleInvalidUserIds = new ArrayList<>();
}

View File

@@ -27,4 +27,10 @@ public class ZiniaoShopMatchResultVo {
@Schema(description = "打开店铺链接") @Schema(description = "打开店铺链接")
private String openStoreUrl; private String openStoreUrl;
@Schema(description = "匹配状态: MATCHED / PENDING / CONFLICT")
private String matchStatus;
@Schema(description = "匹配说明")
private String matchMessage;
} }

View File

@@ -112,6 +112,64 @@ public class ZiniaoAuthService {
return new StoreMatchResult(false, null, null, null, null, null); 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<ZiniaoStaffItemVo> getOrLoadStaffForIndex(String apiKey, Long companyId) {
return getOrLoadStaff(apiKey, companyId);
}
public List<ZiniaoShopCacheDto> 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<ZiniaoStaffItemVo> 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) { private boolean isSkippableUserStoresError(BusinessException ex) {
String message = ex == null ? null : ex.getMessage(); String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) { if (message == null || message.isBlank()) {

View File

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

View File

@@ -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<String, List<ZiniaoShopIndexEntryDto>> grouped = new LinkedHashMap<>();
Set<Long> allInvalidUserIds = new LinkedHashSet<>();
int refreshBatchSize = resolveRefreshBatchSize();
try {
List<String> 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<Long> invalidUserIds = new LinkedHashSet<>();
List<ZiniaoStaffItemVo> staff = ziniaoAuthService.getOrLoadStaffForIndex(apiKey, companyId);
for (Long userId : buildUserIds(staff)) {
if (invalidUserIds.contains(userId)) {
continue;
}
List<ZiniaoShopCacheDto> 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<ZiniaoShopIndexEntryDto> 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<String, List<ZiniaoShopIndexEntryDto>> groupedEntry : grouped.entrySet()) {
String normalizedShopName = groupedEntry.getKey();
List<ZiniaoShopIndexEntryDto> 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<ZiniaoShopIndexEntryDto> 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<String> activeNames, long now) {
List<ZiniaoShopIndexEntryDto> 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<Long> buildUserIds(List<ZiniaoStaffItemVo> staff) {
List<Long> 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 缓存键失败");
}
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
public class ZiniaoShopSwitchService { public class ZiniaoShopSwitchService {
private final ZiniaoAuthService ziniaoAuthService; private final ZiniaoAuthService ziniaoAuthService;
private final ZiniaoShopIndexService ziniaoShopIndexService;
public ZiniaoShopMatchResultVo matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { public ZiniaoShopMatchResultVo matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) {
String normalizedShopName = normalizeShopName(targetShopName); String normalizedShopName = normalizeShopName(targetShopName);
@@ -18,18 +19,27 @@ public class ZiniaoShopSwitchService {
} }
ZiniaoAuthService.StoreMatchResult result = ziniaoAuthService.matchStoreByNameAcrossStaff(normalizedShopName, preferUserId); ZiniaoAuthService.StoreMatchResult result = ziniaoAuthService.matchStoreByNameAcrossStaff(normalizedShopName, preferUserId);
return new ZiniaoShopMatchResultVo( ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
result.matched(), vo.setMatched(result.matched());
result.shopId(), vo.setShopId(result.shopId());
result.shopName(), vo.setShopName(result.shopName());
result.platform(), vo.setPlatform(result.platform());
result.matchedUserId(), vo.setMatchedUserId(result.matchedUserId());
result.openStoreUrl() 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() { 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) { public String normalizeShopName(String value) {

View File

@@ -108,5 +108,9 @@ aiimage:
shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30} shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30}
connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5} connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5}
read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15} read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15}
key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:50} key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:100}
key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:15} 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}

View File

@@ -105,7 +105,7 @@
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span> <span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div> <div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div> <div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div> <div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div> <div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount <div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
@@ -158,7 +158,7 @@
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span> <span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div> <div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div> <div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div> <div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount <div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div> }}</div>
@@ -281,8 +281,7 @@ function loadSessionTasksFromStorage() {
} }
function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) { function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) {
// 只将解析成功且匹配成功的任务放入当前队列中,失败的将留在历史记录里 const validItems = items.filter(i => i.matchStatus === 'MATCHED' && i.success !== false)
const validItems = items.filter(i => i.matched !== false && i.success !== false)
if (validItems.length === 0) return if (validItems.length === 0) return
const newTask: StoredCurrentTask = { const newTask: StoredCurrentTask = {
@@ -326,6 +325,9 @@ function getTaskStatus(taskId?: number) {
function getDisplayError(item: DeleteBrandResultItem) { function getDisplayError(item: DeleteBrandResultItem) {
if (item.error) return item.error if (item.error) return item.error
if (item.matchStatus && item.matchStatus !== 'MATCHED') {
return item.matchMessage || ''
}
const taskId = item.taskId const taskId = item.taskId
if (!taskId) return '' if (!taskId) return ''
return taskDetails.value[taskId]?.task?.errorMessage || '' return taskDetails.value[taskId]?.task?.errorMessage || ''
@@ -339,18 +341,37 @@ function shouldShowProgress(item: DeleteBrandResultItem) {
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) { function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => { return items.map((item) => {
// 只有在既没成功,又显式标记为未匹配时,才做错误兜底 if (item.matchStatus === 'MATCHED') {
if (!item.success && item.matched === false) { return item
return { }
...item, return {
error: item.error || '未匹配到紫鸟店铺', ...item,
openStoreUrl: undefined, 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() { function mergeVisibleItems() {
resultItems.value = [...currentSectionItems.value, ...historySectionItems.value] 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) { function formatProgress(taskId: number) {
if (!taskId) return '' if (!taskId) return ''
const detail = taskDetails.value[taskId] const detail = taskDetails.value[taskId]
@@ -410,11 +427,6 @@ function formatProgressPercent(taskId: number) {
return status === 'SUCCESS' ? 100 : 0 return status === 'SUCCESS' ? 100 : 0
} }
function isTaskRunning(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'RUNNING'
}
function isTaskTerminal(taskId: number) { function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' || status === 'FAILED' return status === 'SUCCESS' || status === 'FAILED'
@@ -477,18 +489,58 @@ function canDownloadTaskResult(item: DeleteBrandResultItem) {
return status === 'SUCCESS' 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[]) { async function refreshTaskDetails(taskIds?: number[]) {
const ids = taskIds || getPollingTaskIds() const ids = taskIds || getPollingTaskIds()
if (!ids.length) return if (!ids.length) return
try { try {
const batch = await getDeleteBrandTaskDetails(ids) const batch = await getDeleteBrandTaskDetails(ids)
let changed = false
for (const detail of batch.items || []) { for (const detail of batch.items || []) {
const id = detail?.task?.id const id = detail?.task?.id
if (typeof id === 'number' && id > 0) { if (typeof id === 'number' && id > 0) {
taskDetails.value[id] = detail taskDetails.value[id] = detail
if (mergeTaskDetailItemsIntoSession(detail)) {
changed = true
}
} }
} }
if (changed) {
syncResultState()
}
} catch { } catch {
// ignore polling failures // ignore polling failures
} }
@@ -649,7 +701,8 @@ async function submitRun() {
})), })),
}) })
const normalizedItems = normalizeDeleteBrandItems(result.items || []) 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 = '解析完成,等待手动点击打开紫鸟进行单任务处理' queuePushResult.value = '解析完成,等待手动点击打开紫鸟进行单任务处理'
queuePayloadText.value = '' queuePayloadText.value = ''
@@ -659,10 +712,12 @@ async function submitRun() {
} }
syncResultState() syncResultState()
if (hasUnmatched) { if (hasPendingItems) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,请检查报错内容。这些文件已自动归档至历史记录。') ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
} else { } else if (hasMatchedItems) {
ElMessage.success('删除品牌解析完成,请手动推送') ElMessage.success('删除品牌解析完成,请手动推送')
} else {
ElMessage.warning('当前没有可推送的已匹配文件,请先等待店铺索引刷新。')
} }
// 更新历史记录,以展示那些未进入队列的失败项 // 更新历史记录,以展示那些未进入队列的失败项
@@ -783,10 +838,7 @@ function findNextAutoRunnableItem() {
} }
async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) { async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) {
if (isPythonQueueBusy()) { if (options?.auto && isPythonQueueBusy()) {
if (!options?.auto) {
ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!')
}
return false return false
} }
@@ -816,7 +868,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
const pushResult = await api.enqueue_json(payload) const pushResult = await api.enqueue_json(payload)
let qpr = '' let qpr = ''
if (pushResult?.success) { 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) const sessionItem = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename)
if (sessionItem) { if (sessionItem) {
@@ -874,10 +926,6 @@ async function triggerItemRun(item: DeleteBrandResultItem) {
} }
} }
function openShop(url: string) {
window.open(url, '_blank')
}
onMounted(() => { onMounted(() => {
loadSessionTasksFromStorage() loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds() const taskIds = getPollingTaskIds()

View File

@@ -211,12 +211,16 @@ export interface DeleteBrandTaskBatchVo {
missingTaskIds: number[] missingTaskIds: number[]
} }
export type DeleteBrandMatchStatus = 'MATCHED' | 'PENDING' | 'CONFLICT' | 'INDEX_STALE'
export interface DeleteBrandResultItem { export interface DeleteBrandResultItem {
resultId?: number resultId?: number
fileKey?: string fileKey?: string
sourceFilename: string sourceFilename: string
shopName?: string shopName?: string
matched?: boolean matched?: boolean
matchStatus?: DeleteBrandMatchStatus
matchMessage?: string
shopId?: string shopId?: string
platform?: string platform?: string
openStoreUrl?: string openStoreUrl?: string