紫鸟查询店铺更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<DeleteBrandResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -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<Object, Object> 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<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) {
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ZiniaoShopIndexRefreshCursorDto> getIndexRefreshStatus() {
|
||||
return ApiResponse.success(ziniaoShopIndexService.getRefreshCursor());
|
||||
}
|
||||
|
||||
@GetMapping("/shops")
|
||||
@Operation(summary = "获取员工可见店铺列表", description = "先通过 API Key 获取 companyId,再按员工 userId 查询该员工有权限的店铺列表,并缓存到当前 session。")
|
||||
@ApiResponses({
|
||||
|
||||
@@ -62,6 +62,24 @@ public class ZiniaoMemoryStoreService {
|
||||
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
|
||||
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<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
|
||||
public int deleteExpired(int limit) {
|
||||
int safeLimit = Math.max(limit, 1);
|
||||
@@ -129,6 +165,14 @@ public class ZiniaoMemoryStoreService {
|
||||
.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) {
|
||||
return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
String message = ex == null ? null : ex.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 缓存键失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="left split-result-main">
|
||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
||||
<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="getQueueStatus(item)" class="files">队列状态:{{ getQueueStatus(item) }}</div>
|
||||
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount
|
||||
@@ -158,7 +158,7 @@
|
||||
<div class="left split-result-main">
|
||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
||||
<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.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount
|
||||
}}</div>
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user