refactor(ziniao/shopkey)+fix(stale): 打破模块循环依赖 + 采集陈旧判死全局化

模块边界(消除 ziniao ↔ shopkey 真实循环依赖):
- 新增 ziniao/service/port/{ShopKeyCatalogPort,ManagedShopNamePort}:消费方声明契约
- shopkey 侧新增 ShopKeyCatalogAdapter(读 shop_key + 白名单状态回写)、
  ManagedShopNameAdapter(店铺名校验),实现上述端口
- ZiniaoApiKeyProvider 改经端口取数,不再 import shopkey 的 Mapper/Entity;
  ZiniaoShopSwitchService 改依赖 ManagedShopNamePort
- 结果:ziniao → shopkey 的 import 归零,依赖单向(shopkey → ziniao)

陈旧判死(G1 全局判死 + D9 条件更新,替代此前的 owner 过滤/旧实体覆盖):
- ShopDataCrawlTaskService.finalizeOwnedStaleTasks → finalizeStaleTasks:去掉 owner 过滤,
  并入 DeleteBrandStaleTaskService 的 stale-check 巡检线(job 锁保证单实例扫描)
- 判死前必须持有任务锁(非阻塞获取,锁被占本轮跳过),FAILED 写入改 status CAS,
  仅在确实由 RUNNING 翻转为 FAILED 时才删缓存与分片(原实现会用扫描期旧实体覆盖在途任务)
- tryFinalizeTask 增加 allowOwnerTakeover 重载:判死场景允许跨实例接管(P1-8 盲区)
- 测试同步:owner 契约用例改为全局判死口径;mock 的 CAS 需先渲染 SQL 片段
  (MyBatis-Plus 的 where 参数延迟填充)才读参数表;新增锁被占跳过的用例

mvn test 2796 全绿
This commit is contained in:
2026-09-14 05:44:37 +08:00
parent 24ada70997
commit 95dfb69a18
13 changed files with 369 additions and 150 deletions
@@ -16,6 +16,7 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskCacheService;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
@@ -80,6 +81,7 @@ public class DeleteBrandStaleTaskService {
private final DistributedJobLockService distributedJobLockService;
private final TaskDistributedLockService taskDistributedLockService;
private final TaskFileJobService taskFileJobService;
private final ShopDataCrawlTaskService shopDataCrawlTaskService;
@Value("${aiimage.temp-dir.retention-hours:24}")
private long tempDirRetentionHours;
@@ -104,6 +106,10 @@ public class DeleteBrandStaleTaskService {
runModuleStaleCheck("brand", brandTaskService::failStaleRunningTasks);
runModuleStaleCheck("appearance-patent", appearancePatentTaskService::finalizeStaleTasks);
runModuleStaleCheck("similar-asin", similarAsinTaskService::finalizeStaleTasks);
// 商品管理采集并入本巡检线(2026-09):此前它自带 @Scheduled 且按 owner 过滤,
// 双实例下 owner 宕机即无人判死(P1-8);并入后由本方法的 job 锁保证单实例扫描,
// 它自己改为全局判死 + 任务锁 + status CAS
runModuleStaleCheck("shop-data-crawl", shopDataCrawlTaskService::finalizeStaleTasks);
// 周期每 2 分钟一轮,各模块 summary 合并为单行,避免定期刷屏
log.info("[stale-check] summary product-risk(s={} f={} x={}/{} p={}) price-track(s={} f={} x={}/{} p={}) shop-match(s={} f={} x={}/{} p={}) patrol-delete(s={} f={} x={}/{} p={}) query-asin(s={} f={} x={}/{} p={}) withdraw(s={} f={} x={}/{} p={}) elapsedMs={} thread={}",
stats.scannedTaskCount, stats.finalizedTaskCount, stats.failedTaskCount, stats.skippedTaskCount,
@@ -52,7 +52,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
@@ -127,23 +126,24 @@ public class ShopDataCrawlTaskService {
@Value("${aiimage.shop-data-crawl.stale-timeout-minutes:30}")
private long staleTimeoutMinutes;
// cron 用独立配置键:此前复用 aiimage.delete-brand-progress.stale-check-cron
// 后台调整「删除品牌巡检频率」会静默改变本模块(商品管理采集)的扫库节奏
@Scheduled(cron = "${aiimage.shop-data-crawl.stale-check-cron:0 */2 * * * *}")
public void finalizeOwnedStaleTasks() {
/**
* 陈旧任务判死:扫描本模块「RUNNING 且 Python 心跳超时」的任务并终结。
*
* 本方法由 {@code DeleteBrandStaleTaskService} 的 stale-check 分布式 job 锁统一调度
* (与 brand / similar-asin / appearance-patent 同一条巡检线),扫描本身是单实例执行的;
* 因此这里不再按 owner_instance_id 过滤:历史实现过滤到 owner 实例 + 终结前 owner 校验,
* 一旦 owner 实例宕机或长期拿不到任务锁,该任务在 30 分钟兜底线上无人判死(P1-8 双实例判死盲区),
* 用户侧一直显示「运行中」,只能等 2 小时的全局限时修复。
* 并发安全由 job 锁(单实例扫描)+ 任务锁(判死前必须持有)+ FAILED 写入的 status CAS 保证。
*/
public void finalizeStaleTasks() {
long minutes = Math.max(1L, staleTimeoutMinutes);
long nowMillis = System.currentTimeMillis();
List<FileTaskEntity> tasks;
try {
// 保留 owner 过滤(2026-09 复核):本模块与 similar-asin 的差别是**没有 job 级分布式锁**
// similarasin 的判死由 delete-brand:stale-check 锁收敛为单实例执行,所以可以全局判死)。
// 这里若去掉 owner 过滤,双节点会各自扫描并推进同一批任务,只靠 tryFinalizeTask 的
// task 锁兜底 —— 正确性尚可但会产生重复扫描与锁竞争。owner 语义也有专属测试覆盖
// ShopDataCrawlOwnerColumnTest),属刻意设计而非遗漏。若要对齐全局判死,应先补 job 锁。
tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, "RUNNING")
.eq(FileTaskEntity::getOwnerInstanceId, currentInstanceId())
.lt(FileTaskEntity::getUpdatedAt, LocalDateTime.now().minusMinutes(minutes))
.last("limit 200"));
} catch (Exception ex) {
@@ -155,22 +155,19 @@ public class ShopDataCrawlTaskService {
for (FileTaskEntity task : tasks) {
long heartbeat = heartbeats.getOrDefault(task.getId(), 0L);
if (heartbeat > 0 && nowMillis - heartbeat < Duration.ofMinutes(minutes).toMillis()) continue;
try {
ensureTaskOwnedByCurrentInstance(task, "finalize stale shop data crawl task");
// 判死前必须持有任务锁:拿不到说明对方(owner 实例 / 文件任务 worker)正在推进该任务,
// 越过锁写 FAILED 会把在途任务误判失败并不可恢复地删掉分片(D9),本轮跳过等下一轮
TaskDistributedLockService.LockHandle taskLock = acquireTaskLock(task.getId(), 0L);
if (taskLock == null) {
log.info("[shop-data-crawl] stale task skipped because task lock is busy taskId={}", task.getId());
continue;
}
try (taskLock) {
if (taskFileJobService.countUnfinishedAssembleJobs(task.getId(), MODULE_TYPE) > 0L) continue;
if (!tryFinalizeTask(task.getId(), true)) {
// 注:此处曾改为条件更新(where status='RUNNING' 的 CAS)以防御「tryFinalizeTask
// 返回 false 含锁被占语义、用扫描期旧实体覆盖会把在途任务误判失败」的风险;
// 但本模块的 stale 扫描已有专属契约测试(ShopDataCrawlOwnerColumnTest /
// ShopDataCrawlCleanupTest)固化「扫描即终结」的行为,改动与契约冲突。
// 保留原实现;如需加固请连同契约测试一起调整。
task.setStatus("FAILED");
task.setErrorMessage("长时间未收到 Python 结果回传,任务已自动失败");
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
taskCacheService.deleteTaskCache(task.getId());
cleanupResultChunksQuietly(task.getId(), "stale task failure");
if (!tryFinalizeTask(task.getId(), true, true)) {
// tryFinalizeTask 返回 false:无结果行(任务停在没有任何回传的阶段)
// 或任务已被终结。前者由本处条件更新为 FAILED,后者被 CAS 挡住不做清理。
markStaleTaskFailedIfStillRunning(task.getId());
}
} catch (TaskOwnerMismatchException ignored) {
// The owner may change between the scan and finalization.
@@ -180,6 +177,31 @@ public class ShopDataCrawlTaskService {
}
}
/**
* 把仍处于 RUNNING 的任务条件更新为 FAILEDstatus CAS),并在更新成功时才清理缓存与结果分片。
* 用条件更新而非扫描期旧实体的 updateById:后者会把已被终结/正在终结的任务覆盖回 FAILED。
*
* @return true 表示本次确实由 RUNNING 翻转为 FAILED
*/
private boolean markStaleTaskFailedIfStillRunning(Long taskId) {
LocalDateTime now = LocalDateTime.now();
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, taskId)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, now)
.set(FileTaskEntity::getFinishedAt, now));
if (updated <= 0) {
log.info("[shop-data-crawl] stale task not flipped to FAILED because status changed taskId={}", taskId);
return false;
}
taskCacheService.deleteTaskCache(taskId);
cleanupResultChunksQuietly(taskId, "stale task failure");
log.warn("[shop-data-crawl] stale task marked FAILED taskId={}", taskId);
return true;
}
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
@@ -578,10 +600,22 @@ public class ShopDataCrawlTaskService {
}
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
return tryFinalizeTask(taskId, fromCompensation, false);
}
/**
* @param allowOwnerTakeover 陈旧判死场景传 true:允许本实例接管 owner 非本机的任务。
* 调用方(stale 巡检)已由 job 锁保证单实例执行,任务级互斥由任务锁(非阻塞获取)保证;
* 若仍按 owner 拒绝,owner 实例宕机时该任务永远无人判死(P1-8 双实例判死盲区)。
*/
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation, boolean allowOwnerTakeover) {
if (taskId == null || taskId <= 0) {
return false;
}
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
// 判死场景用非阻塞获取:锁被占说明对方仍活跃,静默跳过等下一轮,不排队等 10 秒拖慢扫描
TaskDistributedLockService.LockHandle lockHandle = allowOwnerTakeover
? acquireTaskLock(taskId, 0L)
: acquireTaskLock(taskId);
if (lockHandle == null) {
log.info("[shop-data-crawl] tryFinalizeTask skipped because task lock is busy taskId={} fromCompensation={}", taskId, fromCompensation);
return false;
@@ -591,7 +625,9 @@ public class ShopDataCrawlTaskService {
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
ensureTaskOwnedByCurrentInstance(task, "finalize shop data crawl task");
if (!allowOwnerTakeover) {
ensureTaskOwnedByCurrentInstance(task, "finalize shop data crawl task");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
return true;
}
@@ -2698,6 +2734,13 @@ public class ShopDataCrawlTaskService {
return taskDistributedLockService.acquire(MODULE_TYPE, taskId);
}
private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId, long waitMillis) {
if (taskId == null || taskId <= 0) {
return null;
}
return taskDistributedLockService.acquire(MODULE_TYPE, taskId, waitMillis);
}
private void markResultSuccess(FileResultEntity row) {
row.setSuccess(RESULT_SUCCESS);
row.setErrorMessage(null);
@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.nanri.aiimage.modules.ziniao.service.port.ManagedShopNamePort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 把后台店铺管理的校验能力暴露给 ziniao 模块,
* 避免 ziniao 直接依赖 shopkey 的 Service(打破模块循环依赖)。
*/
@Service
@RequiredArgsConstructor
public class ManagedShopNameAdapter implements ManagedShopNamePort {
private final ShopManageService shopManageService;
@Override
public void requireShopByName(String shopName) {
shopManageService.requireShopByName(shopName);
}
}
@@ -0,0 +1,64 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
import com.nanri.aiimage.modules.ziniao.service.port.ShopKeyCatalogPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* shop_key 表对紫鸟侧的只读/回写适配器:把紫鸟需要的字段暴露给 ziniao 模块,
* 使 ziniao 不再直接引用 shopkey 的 Mapper 与 Entity(打破模块循环依赖)。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ShopKeyCatalogAdapter implements ShopKeyCatalogPort {
private final ShopKeyMapper shopKeyMapper;
@Override
public List<ShopKeyRecord> listAllOrderByIdDesc() {
return shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>()
.orderByDesc(ShopKeyEntity::getId))
.stream()
.filter(java.util.Objects::nonNull)
.map(entity -> new ShopKeyRecord(
entity.getId(),
entity.getZiniaoToken(),
entity.getZiniaoAccountName(),
entity.getProxyUrl(),
entity.getIpWhitelistFailCount()))
.toList();
}
@Override
public long count() {
Long total = shopKeyMapper.selectCount(null);
return total == null ? 0L : total;
}
@Override
public void updateIpWhitelist(List<Long> ids, String status, String message, Integer failCount) {
if (ids == null || ids.isEmpty()) {
// 无对应 shop_key 行时不该发空 update(会命中全表条件)
log.warn("[shop-key] 白名单状态回写被跳过:ids 为空 status={}", status);
return;
}
LambdaUpdateWrapper<ShopKeyEntity> update = new LambdaUpdateWrapper<ShopKeyEntity>()
.in(ShopKeyEntity::getId, ids)
.set(ShopKeyEntity::getIpWhitelistStatus, status)
.set(ShopKeyEntity::getIpWhitelistCheckedAt, LocalDateTime.now())
.set(ShopKeyEntity::getIpWhitelistMessage, message);
if (failCount != null) {
update.set(ShopKeyEntity::getIpWhitelistFailCount, failCount);
}
shopKeyMapper.update(null, update);
}
}
@@ -1,20 +1,22 @@
package com.nanri.aiimage.modules.ziniao.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoProxyResolver;
import com.nanri.aiimage.modules.ziniao.service.port.ShopKeyCatalogPort;
import com.nanri.aiimage.modules.ziniao.service.port.ShopKeyCatalogPort.ShopKeyRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 紫鸟令牌(shop_key 表)的门面:负责按令牌聚合账号信息、缓存代理地址、维护白名单检测状态。
* 表数据读写经 {@link ShopKeyCatalogPort} 由 shopkey 模块提供,避免 ziniao 反向依赖 shopkey。
*/
@Service
@RequiredArgsConstructor
public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
@@ -29,29 +31,27 @@ public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
*/
public static final int IP_WHITELIST_MAX_FAIL_COUNT = 3;
private final ShopKeyMapper shopKeyMapper;
private static final long PROXY_URL_CACHE_MILLIS = 60_000L;
private final ShopKeyCatalogPort shopKeyCatalogPort;
private volatile Map<String, String> cachedProxyUrls;
private volatile long proxyUrlCacheLoadedAt;
public List<ApiKeyAccount> listApiKeyAccounts() {
List<ShopKeyEntity> entities = shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>()
.orderByDesc(ShopKeyEntity::getId));
Map<String, List<ShopKeyEntity>> entitiesByApiKey = new LinkedHashMap<>();
for (ShopKeyEntity entity : entities) {
String apiKey = normalizeApiKey(entity == null ? null : entity.getZiniaoToken());
Map<String, List<ShopKeyRecord>> recordsByApiKey = new LinkedHashMap<>();
for (ShopKeyRecord record : shopKeyCatalogPort.listAllOrderByIdDesc()) {
String apiKey = normalizeApiKey(record == null ? null : record.ziniaoToken());
if (apiKey == null) {
continue;
}
entitiesByApiKey.computeIfAbsent(apiKey, ignored -> new ArrayList<>()).add(entity);
recordsByApiKey.computeIfAbsent(apiKey, ignored -> new ArrayList<>()).add(record);
}
return entitiesByApiKey.entrySet().stream()
return recordsByApiKey.entrySet().stream()
.map(entry -> new ApiKeyAccount(
entry.getKey(),
resolveAccountName(entry.getValue()),
entry.getValue().stream().map(ShopKeyEntity::getId).filter(java.util.Objects::nonNull).toList(),
entry.getValue().stream().map(ShopKeyRecord::id).filter(Objects::nonNull).toList(),
resolveProxyUrl(entry.getValue()),
resolveIpWhitelistFailCount(entry.getValue())
))
@@ -96,18 +96,18 @@ public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
}
}
private String resolveProxyUrl(List<ShopKeyEntity> entities) {
return entities.stream()
.map(ShopKeyEntity::getProxyUrl)
private String resolveProxyUrl(List<ShopKeyRecord> records) {
return records.stream()
.map(ShopKeyRecord::proxyUrl)
.filter(proxy -> proxy != null && !proxy.isBlank())
.map(String::trim)
.findFirst()
.orElse(null);
}
private Integer resolveIpWhitelistFailCount(List<ShopKeyEntity> entities) {
return entities.stream()
.map(ShopKeyEntity::getIpWhitelistFailCount)
private Integer resolveIpWhitelistFailCount(List<ShopKeyRecord> records) {
return records.stream()
.map(ShopKeyRecord::ipWhitelistFailCount)
.filter(count -> count != null)
.max(Integer::compareTo)
.orElse(0);
@@ -128,8 +128,7 @@ public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
}
public boolean hasApiKey() {
Long total = shopKeyMapper.selectCount(new LambdaQueryWrapper<ShopKeyEntity>());
return total != null && total > 0;
return shopKeyCatalogPort.count() > 0;
}
public void markIpWhitelistAllowed(ApiKeyAccount account) {
@@ -168,20 +167,12 @@ public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
if (account == null || account.shopKeyIds().isEmpty()) {
return;
}
LambdaUpdateWrapper<ShopKeyEntity> update = new LambdaUpdateWrapper<ShopKeyEntity>()
.in(ShopKeyEntity::getId, account.shopKeyIds())
.set(ShopKeyEntity::getIpWhitelistStatus, status)
.set(ShopKeyEntity::getIpWhitelistCheckedAt, LocalDateTime.now())
.set(ShopKeyEntity::getIpWhitelistMessage, truncateMessage(message));
if (failCount != null) {
update.set(ShopKeyEntity::getIpWhitelistFailCount, failCount);
}
shopKeyMapper.update(null, update);
shopKeyCatalogPort.updateIpWhitelist(account.shopKeyIds(), status, truncateMessage(message), failCount);
}
private String resolveAccountName(List<ShopKeyEntity> entities) {
return entities.stream()
.map(ShopKeyEntity::getZiniaoAccountName)
private String resolveAccountName(List<ShopKeyRecord> records) {
return records.stream()
.map(ShopKeyRecord::ziniaoAccountName)
.filter(name -> name != null && !name.isBlank())
.map(String::trim)
.findFirst()
@@ -1,8 +1,8 @@
package com.nanri.aiimage.modules.ziniao.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.port.ManagedShopNamePort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -12,7 +12,7 @@ public class ZiniaoShopSwitchService {
private final ZiniaoAuthService ziniaoAuthService;
private final ZiniaoShopIndexService ziniaoShopIndexService;
private final ShopManageService shopManageService;
private final ManagedShopNamePort managedShopNamePort;
public ZiniaoShopMatchResultVo matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) {
String normalizedShopName = requireManagedShopName(targetShopName);
@@ -62,7 +62,7 @@ public class ZiniaoShopSwitchService {
private String requireManagedShopName(String targetShopName) {
String normalizedShopName = normalizeShopName(targetShopName);
if (!normalizedShopName.isBlank()) {
shopManageService.requireShopByName(normalizedShopName);
managedShopNamePort.requireShopByName(normalizedShopName);
}
return normalizedShopName;
}
@@ -0,0 +1,12 @@
package com.nanri.aiimage.modules.ziniao.service.port;
/**
* 后台店铺管理数据(shop_manage 表)的只读校验端口。
*
* 由 shopkey 模块实现,用于替代 ziniao → shopkey 的直接类依赖,避免模块循环依赖。
*/
public interface ManagedShopNamePort {
/** 校验店铺名在后台店铺管理中已维护,未找到则抛出 BusinessException。 */
void requireShopByName(String shopName);
}
@@ -0,0 +1,27 @@
package com.nanri.aiimage.modules.ziniao.service.port;
import java.util.List;
/**
* 紫鸟令牌数据来源端口。
*
* 数据实际存放在 shop_key 表(shopkey 模块),这里只声明紫鸟侧需要的最小读取/回写契约,
* 由 shopkey 模块提供实现;这样 ziniao 不再直接依赖 shopkey 的 Mapper/Entity
* 模块间保持单向依赖(shopkey → ziniao)。
*/
public interface ShopKeyCatalogPort {
/** 全量紫鸟令牌记录,按 id 倒序(最新维护的排前面)。 */
List<ShopKeyRecord> listAllOrderByIdDesc();
/** 令牌记录总行数。 */
long count();
/** 按 id 批量回写白名单检测结果;failCount 为 null 表示不改动该字段。 */
void updateIpWhitelist(List<Long> ids, String status, String message, Integer failCount);
/** shop_key 中与紫鸟令牌相关的字段子集。 */
record ShopKeyRecord(Long id, String ziniaoToken, String ziniaoAccountName,
String proxyUrl, Integer ipWhitelistFailCount) {
}
}