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
@@ -216,7 +216,7 @@ class DeleteBrandStaleTaskServiceTest {
return new DeleteBrandStaleTaskService(
fileTaskMapper, deleteBrandTaskCacheService, deleteBrandTaskStorageService, deleteBrandRunService,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
deleteBrandProgressProperties, null, taskDistributedLockService, taskFileJobService);
deleteBrandProgressProperties, null, taskDistributedLockService, taskFileJobService, null);
}
private void lockAvailable() {
@@ -77,6 +77,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -85,7 +86,7 @@ import static org.mockito.Mockito.when;
* 覆盖四类资源释放路径:
* 1. 删除——deleteHistory 重建每日累计文件时临时文件在 finally 中删除、旧对象在行删除后按引用回收、
* 新上传对象在事务回滚时注册清理;任务删除只解绑成员不触碰累计文件对象。
* 2. 超时——finalizeOwnedStaleTasks 将陈旧 RUNNING 任务标记 FAILED 并清理残留分片(chunk 行 + payload),
* 2. 超时——finalizeStaleTasks 将陈旧 RUNNING 任务标记 FAILED 并清理残留分片(chunk 行 + payload),
* 心跳活跃的任务被跳过;扫表失败安全返回。
* 3. 重复回传——同一分片重复提交不重复计数、被拒 payload 释放;scope 状态写入失败时
* 回滚本次插入的分片行与 payload。
@@ -195,6 +196,8 @@ class ShopDataCrawlCleanupTest {
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong(), anyLong()))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
@@ -230,16 +233,13 @@ class ShopDataCrawlCleanupTest {
.thenAnswer(invocation -> {
com.baomidou.mybatisplus.core.conditions.Wrapper<FileTaskEntity> wrapper =
invocation.getArgument(1);
// getParamNameValuePairs 在 AbstractWrapper 上(不在 Wrapper 接口),用反射取条件参数
java.util.Map<String, Object> params;
try {
java.lang.reflect.Method m = wrapper.getClass().getMethod("getParamNameValuePairs");
@SuppressWarnings("unchecked")
java.util.Map<String, Object> extracted = (java.util.Map<String, Object>) m.invoke(wrapper);
params = extracted;
} catch (Exception ex) {
params = java.util.Map.of();
}
// where 条件的值由 MyBatis-Plus 延迟填充:先渲染一次 SQL 片段才会写入参数
// (真实执行同样先渲染,故生产语义不变);反射取表会漏掉这些延迟项,改用直调重载。
@SuppressWarnings("unchecked")
java.util.Map<String, Object> params =
wrapper instanceof com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<?> lambdaWrapper
? (java.util.Map<String, Object>) renderParams(lambdaWrapper)
: java.util.Map.of();
java.util.Optional<Object> idValue = params.values().stream()
.filter(v -> v instanceof Long).findFirst();
if (idValue.isEmpty()) {
@@ -732,15 +732,15 @@ class ShopDataCrawlCleanupTest {
// 陈旧任务扫描失败安全返回,不抛异常。
doThrow(new IllegalStateException("scan failed"))
.when(fileTaskMapper).selectList(any());
service.finalizeOwnedStaleTasks();
service.finalizeStaleTasks();
}
// ---- 2. 超时路径 ----
@Test
void test_task_039_daily_file_cleanup_timeout_stale_task_failed() throws Exception {
// 超时:陈旧 RUNNING 任务在锁不可得时由调度器标记 FAILED,残留分片行与 payload 全部清理;
// 心跳活跃的任务被跳过;扫表失败安全返回。
// 超时:拿到任务锁的陈旧 RUNNING 任务由 status CAS 置 FAILED,残留分片行与 payload 全部清理;
// 心跳活跃的任务被跳过;任务锁被占(对方仍在推进)时本轮既不判死也不清理(D9);扫表失败安全返回。
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 1L);
FileResultEntity row = addResultRow(8201L, 2L, -1, SHOP_NAME, null);
FileTaskEntity staleTask = taskEntity(2L, "RUNNING");
@@ -750,21 +750,28 @@ class ShopDataCrawlCleanupTest {
FileTaskEntity activeTask = taskEntity(3L, "RUNNING");
activeTask.setUpdatedAt(LocalDateTime.now().minusHours(2));
taskStore.put(3L, activeTask);
FileResultEntity lockedRow = addResultRow(8203L, 4L, -1, SHOP_NAME, null);
FileTaskEntity lockedTask = taskEntity(4L, "RUNNING");
lockedTask.setUpdatedAt(LocalDateTime.now().minusHours(2));
taskStore.put(4L, lockedTask);
addChunk(2L, 1, "rustfs:stale-payload");
addChunk(3L, 1, "rustfs:active-payload");
addChunk(4L, 1, "rustfs:locked-payload");
lastJobTaskId = 2L;
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), eq(2L)))
// 任务 4 的任务锁被占:判死前必须先持锁,拿不到就整轮跳过(不写 FAILED、不删分片)
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), eq(4L), anyLong()))
.thenReturn(null);
lenient().when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
String segment = wrapper instanceof LambdaQueryWrapper<?> query && query.getSqlSegment() != null
? query.getSqlSegment() : "";
List<FileTaskEntity> result = new ArrayList<>();
if (segment.contains("ownerInstanceId")) {
// 2026-09 全局判死:stale 扫描不再按 owner_instance_id 过滤,只按模块 + RUNNING
if (segment.contains("moduleType")) {
for (FileTaskEntity stored : taskStore.values()) {
if ("RUNNING".equals(stored.getStatus())
&& "instance-a".equals(stored.getOwnerInstanceId())) {
&& MODULE_TYPE.equals(stored.getModuleType())) {
result.add(copyTask(stored));
}
}
@@ -782,21 +789,27 @@ class ShopDataCrawlCleanupTest {
return heartbeats;
});
service.finalizeOwnedStaleTasks();
service.finalizeStaleTasks();
// 任务 2 有结果行:拿到锁后走模块自身的最终化路径(Python 中断语义),
// 且因无可用结果 → 顺带释放分片("terminal task without workbook"
assertEquals("FAILED", taskStore.get(2L).getStatus(), "陈旧任务标记 FAILED");
assertTrue(taskStore.get(2L).getErrorMessage().contains("自动失败"), "错误消息可识别");
assertTrue(taskStore.get(2L).getErrorMessage().contains("中断"), "错误消息为中断语义");
assertTrue(dbChunks.stream().noneMatch(c -> Objects.equals(c.getTaskId(), 2L)),
"陈旧任务残留分片行清理");
assertTrue(deletedPayloads.contains("rustfs:stale-payload"), "陈旧任务分片 payload 释放");
assertEquals("RUNNING", taskStore.get(3L).getStatus(), "心跳活跃任务跳过");
assertFalse(deletedPayloads.contains("rustfs:active-payload"), "活跃任务分片 payload 保留");
assertEquals("RUNNING", taskStore.get(4L).getStatus(), "任务锁被占时本轮不判死");
assertTrue(dbChunks.stream().anyMatch(c -> Objects.equals(c.getTaskId(), 4L)), "任务锁被占时保留分片行");
assertFalse(deletedPayloads.contains("rustfs:locked-payload"), "任务锁被占时保留分片 payload");
verify(taskCacheService).deleteTaskCache(2L);
verify(taskCacheService, never()).deleteTaskCache(4L);
// 扫表失败安全返回,不影响任何资源。
doThrow(new IllegalStateException("scan failed"))
.when(fileTaskMapper).selectList(any());
service.finalizeOwnedStaleTasks();
service.finalizeStaleTasks();
}
// ---- 3. 重复回传与累计文件失败 ----
@@ -1266,6 +1279,13 @@ class ShopDataCrawlCleanupTest {
return copy;
}
/** 渲染一次 SQL 片段以补齐延迟写入的参数,再返回参数表。 */
private static java.util.Map<String, Object> renderParams(
com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<?> wrapper) {
wrapper.getSqlSegment();
return wrapper.getParamNameValuePairs();
}
private FileTaskEntity copyTask(FileTaskEntity source) {
FileTaskEntity copy = new FileTaskEntity();
copy.setId(source.getId());
@@ -70,6 +70,12 @@ import static org.mockito.Mockito.when;
* 迁移后写入/读取/查询都走 biz_file_task.owner_instance_id 显式列
* V92 迁移新增列并补 (owner_instance_id, status, updated_at) 索引),
* JSON 解析不再参与 owner 判定,stale 扫描直接按列过滤。
*
* 2026-09 复核修订:stale 扫描改为**全局判死**(不再按 owner_instance_id 过滤)。
* owner 过滤 + 终结前 owner 校验在双实例下是 P1-8 判死盲区:owner 实例宕机/长期拿不到任务锁时,
* 该任务在 30 分钟兜底线上无人判死。改造后单实例扫描由 DeleteBrandStaleTaskService 的
* stale-check job 锁保证,任务级互斥由任务锁(判死前必须持有)+ status CAS 保证。
* 本类继续覆盖 owner 列的写入/读取/兼容(createTask 落列、ownerInstanceIdOf 兼容 JSON 兜底)。
*/
@ExtendWith(MockitoExtension.class)
class ShopDataCrawlOwnerColumnTest {
@@ -142,6 +148,8 @@ class ShopDataCrawlOwnerColumnTest {
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong(), anyLong()))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
@@ -153,12 +161,31 @@ class ShopDataCrawlOwnerColumnTest {
return value == null ? "" : value.trim();
});
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
// 2026-09:陈旧扫描的 FAILED 写入改为条件更新(where status='RUNNING' 的 CAS),
// 走的是 update(entity=null, wrapper);需要 stub 返回 1,否则默认 0 会被当成"未翻转"(任务保持 RUNNING)。
// 2026-09:陈旧扫描的 FAILED 写入改为条件更新(where id=? and status='RUNNING' 的 CAS),
// 走的是 update(entity=null, wrapper)。这里按 wrapper 参数模拟 DB:只有仍为 RUNNING 的行才命中,
// 命中后落回内存表(与真实 DB 语义一致,第二次扫描因终态不再命中)。
// 注意用 isNull()Mockito 2+ 的 any(Class) 不匹配 null。
lenient().when(fileTaskMapper.update(org.mockito.ArgumentMatchers.isNull(),
any(com.baomidou.mybatisplus.core.conditions.Wrapper.class)))
.thenReturn(1);
.thenAnswer(invocation -> {
com.baomidou.mybatisplus.core.conditions.Wrapper<FileTaskEntity> wrapper =
invocation.getArgument(1);
java.util.Map<String, Object> params = extractParamValues(wrapper);
java.util.Optional<Object> idValue = params.values().stream()
.filter(value -> value instanceof Long)
.findFirst();
if (idValue.isEmpty()) {
return 0;
}
FileTaskEntity stored = dbTask((Long) idValue.get());
if (stored == null || !"RUNNING".equals(stored.getStatus())) {
return 0;
}
stored.setStatus("FAILED");
stored.setUpdatedAt(LocalDateTime.now());
stored.setFinishedAt(LocalDateTime.now());
return 1;
});
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
lenient().when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
@@ -179,15 +206,16 @@ class ShopDataCrawlOwnerColumnTest {
assertEquals("instance-a", dbTask(3401L).getOwnerInstanceId(), "显式列写入当前实例 id");
assertTrue(dbTask(3401L).getRequestJson().contains("ownerInstanceId"), "兼容字段仍保留在快照 JSON");
// stale 扫描按显式列过滤当前实例 RUNNING 任务,命中 owner 路由
// stale 扫描命中 RUNNING 任务2026-09 起全局判死,owner 列不再参与扫描过滤)
runStaleScan();
assertEquals(1, lastScan.size(), "stale 扫描按 owner_instance_id 列过滤命中");
assertEquals(1, lastScan.size(), "stale 扫描命中 RUNNING 任务");
assertEquals(3401L, lastScan.get(0).getId());
}
@Test
void test_task_034_owner_normal_multiple_items() {
// 批量场景:多个任务各自带 owner 列,扫描只返回当前实例的任务,其他实例不命中。
// 批量场景:owner 列不再参与 stale 扫描过滤(全局判死),跨实例 RUNNING 任务一并进入候选,
// 单实例扫描由 stale-check job 锁保证;终态任务不命中。
dbTasks.add(task(3402L, "instance-a", "RUNNING"));
dbTasks.add(task(3403L, "instance-a", "RUNNING"));
dbTasks.add(task(3404L, "instance-b", "RUNNING"));
@@ -195,7 +223,7 @@ class ShopDataCrawlOwnerColumnTest {
runStaleScan();
assertEquals(2, lastScan.size(), "只返回当前实例 RUNNING 任务");
assertEquals(3, lastScan.size(), "全局判死:跨实例 RUNNING 任务都属于候选");
assertEquals(3402L, lastScan.get(0).getId());
assertEquals(3403L, lastScan.get(1).getId());
}
@@ -260,17 +288,19 @@ class ShopDataCrawlOwnerColumnTest {
dbTasks.add(task(10000L + i, "instance-a", "RUNNING"));
}
runStaleScan();
assertEquals(40, lastScan.size(), "大量任务逐一命中,无重复无丢失");
// 3408owner 为长实例 id)在全局判死下同样是候选,40 + 1
assertEquals(41, lastScan.size(), "大量任务逐一命中,无重复无丢失");
}
@Test
void test_task_034_owner_invalid_input_rejected() {
// 非法参数:无 owner 列的 RUNNING 任务不属于任何实例,扫描不命中(区别于旧 JSON 兼容分支)。
// 非法参数:无 owner 列的 RUNNING 任务同样进入全局判死候选(owner 不再是扫描条件),
// 旧 JSON 兼容分支仍可读出 owner。
dbTasks.add(task(3409L, null, "RUNNING"));
runStaleScan();
assertEquals(0, lastScan.size(), "owner 列缺失的任务不属于当前实例");
assertEquals(1, lastScan.size(), "owner 列缺失不影响全局判死候选集");
// 归属性判定对缺 owner 列的任务放行兼容读取,但不归属任何实例。
FileTaskEntity legacy = task(3410L, null, "RUNNING");
legacy.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
@@ -287,7 +317,7 @@ class ShopDataCrawlOwnerColumnTest {
service.ensureTaskOwnedByCurrentInstance(foreign, "submit shop data crawl result"));
doThrow(new RuntimeException("db down")).when(fileTaskMapper).selectList(any());
service.finalizeOwnedStaleTasks();
service.finalizeStaleTasks();
assertTrue(lastScan.isEmpty(), "DB 异常时扫描降级为空");
}
@@ -353,12 +383,14 @@ class ShopDataCrawlOwnerColumnTest {
}
private void runStaleScan() {
service.finalizeOwnedStaleTasks();
service.finalizeStaleTasks();
}
/**
* 按 SQL 片段中出现的列名与参数占位符顺序提取 owner_instance_id / status 的查询值,
* 按 SQL 片段中出现的列名与参数占位符顺序提取 status 的查询值,
* 模拟 MySQL 按显式列过滤(与生产查询的语义一致,仅用于筛选 dbTasks)。
*
* owner 维度已于 2026-09 从 stale 扫描中去掉(全局判死),因此这里不再解析 ownerInstanceId。
*/
@SuppressWarnings("unchecked")
private List<FileTaskEntity> applyTaskScanFilter(Wrapper<FileTaskEntity> wrapper, List<FileTaskEntity> candidates) {
@@ -367,24 +399,35 @@ class ShopDataCrawlOwnerColumnTest {
}
String sql = query.getSqlSegment();
Map<String, Object> params = query.getParamNameValuePairs();
final String[] ownerFilter = {null};
final String[] statusFilter = {null};
if (sql != null && sql.contains("ownerInstanceId")) {
if (sql != null) {
Matcher matcher = Pattern.compile("([a-zA-Z_]+)\\s*=\\s*#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9]+)\\}")
.matcher(sql);
while (matcher.find()) {
String column = matcher.group(1);
Object value = params.get(matcher.group(2));
if ("ownerInstanceId".equals(column) && value instanceof String s) {
ownerFilter[0] = s;
} else if ("status".equals(column) && value instanceof String s) {
if ("status".equals(column) && value instanceof String s) {
statusFilter[0] = s;
}
}
}
return candidates.stream()
.filter(t -> ownerFilter[0] == null || Objects.equals(ownerFilter[0], t.getOwnerInstanceId()))
.filter(t -> statusFilter[0] == null || Objects.equals(statusFilter[0], t.getStatus()))
.toList();
}
/**
* 取条件更新 wrapper 的参数表。
* 注意:where 条件对应的值由 MyBatis-Plus **延迟填充**——必须先触发一次 SQL 片段渲染
* getSqlSegment)才会写入参数表;真实执行时同样先渲染 SQL,所以生产语义不受影响。
* 反射拿参数表同样会漏掉延迟项,这里统一走 LambdaUpdateWrapper 直调 + 先渲染。
*/
@SuppressWarnings("unchecked")
private Map<String, Object> extractParamValues(Wrapper<FileTaskEntity> wrapper) {
if (wrapper instanceof com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<?> lambdaWrapper) {
lambdaWrapper.getSqlSegment();
return (Map<String, Object>) lambdaWrapper.getParamNameValuePairs();
}
return Map.of();
}
}
@@ -1,41 +1,26 @@
package com.nanri.aiimage.modules.ziniao.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import com.nanri.aiimage.modules.ziniao.service.port.ShopKeyCatalogPort;
import com.nanri.aiimage.modules.ziniao.service.port.ShopKeyCatalogPort.ShopKeyRecord;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ZiniaoApiKeyProviderTest {
@BeforeAll
static void initializeMybatisMetadata() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "ziniao-api-key-provider-test"),
ShopKeyEntity.class
);
}
@Test
void duplicateNormalizedTokensShareOneRefreshAccountAndAllRecordIds() {
ShopKeyMapper mapper = mock(ShopKeyMapper.class);
ShopKeyEntity latest = shopKey(12L, " Bearer duplicate-key ", "最新账号");
ShopKeyEntity older = shopKey(8L, "duplicate-key", "旧账号");
when(mapper.selectList(any())).thenReturn(List.of(latest, older));
ShopKeyCatalogPort port = mock(ShopKeyCatalogPort.class);
ShopKeyRecord latest = shopKey(12L, " Bearer duplicate-key ", "最新账号");
ShopKeyRecord older = shopKey(8L, "duplicate-key", "旧账号");
when(port.listAllOrderByIdDesc()).thenReturn(List.of(latest, older));
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(mapper);
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(port);
List<ZiniaoApiKeyProvider.ApiKeyAccount> accounts = provider.listApiKeyAccounts();
@@ -47,8 +32,8 @@ class ZiniaoApiKeyProviderTest {
@Test
void whitelistResultUpdatesEveryRecordForTheNormalizedToken() {
ShopKeyMapper mapper = mock(ShopKeyMapper.class);
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(mapper);
ShopKeyCatalogPort port = mock(ShopKeyCatalogPort.class);
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(port);
ZiniaoApiKeyProvider.ApiKeyAccount account = new ZiniaoApiKeyProvider.ApiKeyAccount(
"duplicate-key",
"账号",
@@ -57,14 +42,21 @@ class ZiniaoApiKeyProviderTest {
provider.markIpWhitelistBlocked(account, "当前服务器 IP 未加入紫鸟白名单");
verify(mapper).update(isNull(), any(Wrapper.class));
verify(port).updateIpWhitelist(List.of(12L, 8L), ZiniaoApiKeyProvider.IP_WHITELIST_STATUS_BLOCKED,
"当前服务器 IP 未加入紫鸟白名单", 1);
}
private ShopKeyEntity shopKey(long id, String token, String accountName) {
ShopKeyEntity entity = new ShopKeyEntity();
entity.setId(id);
entity.setZiniaoToken(token);
entity.setZiniaoAccountName(accountName);
return entity;
@Test
void recordsWithoutValidTokenAreIgnored() {
ShopKeyCatalogPort port = mock(ShopKeyCatalogPort.class);
when(port.listAllOrderByIdDesc()).thenReturn(List.of(shopKey(3L, " ", "空令牌账号")));
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(port);
assertEquals(List.of(), provider.listApiKeyAccounts());
}
private ShopKeyRecord shopKey(long id, String token, String accountName) {
return new ShopKeyRecord(id, token, accountName, null, 0);
}
}
@@ -1,7 +1,7 @@
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.service.port.ManagedShopNamePort;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,6 +12,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -24,15 +25,14 @@ class ZiniaoShopSwitchServiceTest {
@Mock
private ZiniaoShopIndexService ziniaoShopIndexService;
@Mock
private ShopManageService shopManageService;
private ManagedShopNamePort managedShopNamePort;
@Test
void missingManagedShopStopsIndexedLookup() {
String message = "后台店铺管理中未找到店铺:缺失店铺,请先添加店铺信息";
when(shopManageService.requireShopByName("缺失店铺"))
.thenThrow(new BusinessException(message));
doThrow(new BusinessException(message)).when(managedShopNamePort).requireShopByName("缺失店铺");
ZiniaoShopSwitchService service = new ZiniaoShopSwitchService(
ziniaoAuthService, ziniaoShopIndexService, shopManageService);
ziniaoAuthService, ziniaoShopIndexService, managedShopNamePort);
BusinessException exception = assertThrows(BusinessException.class,
() -> service.findIndexedStoreByName("缺失店铺", false));
@@ -47,22 +47,22 @@ class ZiniaoShopSwitchServiceTest {
expected.setMatched(true);
when(ziniaoShopIndexService.findIndexedStoreByName("测试店铺", false)).thenReturn(expected);
ZiniaoShopSwitchService service = new ZiniaoShopSwitchService(
ziniaoAuthService, ziniaoShopIndexService, shopManageService);
ziniaoAuthService, ziniaoShopIndexService, managedShopNamePort);
ZiniaoShopMatchResultVo actual = service.findIndexedStoreByName(" 测试店铺 ", false);
assertSame(expected, actual);
InOrder order = inOrder(shopManageService, ziniaoShopIndexService);
order.verify(shopManageService).requireShopByName("测试店铺");
InOrder order = inOrder(managedShopNamePort, ziniaoShopIndexService);
order.verify(managedShopNamePort).requireShopByName("测试店铺");
order.verify(ziniaoShopIndexService).findIndexedStoreByName("测试店铺", false);
}
@Test
void missingManagedShopStopsDirectStaffMatch() {
when(shopManageService.requireShopByName("缺失店铺"))
.thenThrow(new BusinessException("后台店铺管理中未找到店铺:缺失店铺,请先添加店铺信息"));
doThrow(new BusinessException("后台店铺管理中未找到店铺:缺失店铺,请先添加店铺信息"))
.when(managedShopNamePort).requireShopByName("缺失店铺");
ZiniaoShopSwitchService service = new ZiniaoShopSwitchService(
ziniaoAuthService, ziniaoShopIndexService, shopManageService);
ziniaoAuthService, ziniaoShopIndexService, managedShopNamePort);
assertThrows(BusinessException.class,
() -> service.matchStoreByNameAcrossStaff("缺失店铺", 12L));
@@ -72,12 +72,12 @@ class ZiniaoShopSwitchServiceTest {
@Test
void blankShopNameDoesNotQueryManagedShopOrZiniao() {
ZiniaoShopSwitchService service = new ZiniaoShopSwitchService(
ziniaoAuthService, ziniaoShopIndexService, shopManageService);
ziniaoAuthService, ziniaoShopIndexService, managedShopNamePort);
ZiniaoShopMatchResultVo result = service.findIndexedStoreByName("  ", false);
assertEquals(ZiniaoShopIndexService.MATCH_STATUS_PENDING, result.getMatchStatus());
assertEquals("店铺名为空,无法匹配索引", result.getMatchMessage());
verifyNoInteractions(shopManageService, ziniaoShopIndexService, ziniaoAuthService);
verifyNoInteractions(managedShopNamePort, ziniaoShopIndexService, ziniaoAuthService);
}
}