菜单修改优化

This commit is contained in:
supernijia
2026-07-28 13:40:19 +08:00
parent 4ddb8b47b0
commit 7c3c9e53a0
90 changed files with 9388 additions and 1545 deletions
@@ -0,0 +1,99 @@
package com.nanri.aiimage.modules.admin.service;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AdminUserServiceTest {
@Test
void createUserStoresSuppliedDirectPermissionsInSameTransactionalMethod() throws Exception {
AdminUserMapper userMapper = mock(AdminUserMapper.class);
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class);
AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService);
AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserCreateRequest request = new AdminUserCreateRequest();
request.setUsername("created-user");
request.setPassword("secret1");
request.setRole("normal");
request.setColumnIds(List.of(11L, 12L));
when(authSupport.currentRole(operator)).thenReturn("admin");
when(passwordEncoder.hash("secret1")).thenReturn("hashed");
when(userMapper.insert(any(AdminUserEntity.class))).thenAnswer(invocation -> {
invocation.<AdminUserEntity>getArgument(0).setId(20L);
return 1;
});
Long createdId = service.createUser(operator, request);
assertThat(createdId).isEqualTo(20L);
ArgumentCaptor<UserColumnPermissionUpdateRequest> permissions =
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), permissions.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
assertThat(permissions.getValue().getColumnIds()).containsExactly(11L, 12L);
assertThat(AdminUserService.class
.getMethod("createUser", AdminUserEntity.class, AdminUserCreateRequest.class)
.isAnnotationPresent(Transactional.class)).isTrue();
}
@Test
void updateUserCanReplaceOnlyDirectPermissionsTransactionally() throws Exception {
AdminUserMapper userMapper = mock(AdminUserMapper.class);
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class);
AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService);
AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserEntity target = user(20L, "normal", 0, 10L);
AdminUserUpdateRequest request = new AdminUserUpdateRequest();
request.setColumnIds(List.of(21L));
when(authSupport.currentRole(operator)).thenReturn("admin");
when(userMapper.selectById(20L)).thenReturn(target);
service.updateUser(operator, 20L, request);
verify(userMapper, never()).update(any(), any());
ArgumentCaptor<UserColumnPermissionUpdateRequest> permissions =
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), permissions.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
assertThat(permissions.getValue().getColumnIds()).containsExactly(21L);
assertThat(AdminUserService.class
.getMethod("updateUser", AdminUserEntity.class, Long.class, AdminUserUpdateRequest.class)
.isAnnotationPresent(Transactional.class)).isTrue();
}
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
user.setRole(role);
user.setIsAdmin(isAdmin);
user.setCreatedById(createdById);
return user;
}
}
@@ -0,0 +1,52 @@
package com.nanri.aiimage.modules.admin.support;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.modules.auth.service.JwtService;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class AdminAuthSupportTest {
@Test
void explicitLowestIdAdminIsNotPromotedToSuperAdmin() {
AdminUserMapper userMapper = mock(AdminUserMapper.class);
AdminAuthSupport support = new AdminAuthSupport(
mock(JwtService.class), userMapper, mock(AuthProperties.class));
AdminUserEntity user = user(1L, "admin", 1, null);
assertThat(support.currentRole(user)).isEqualTo("admin");
verify(userMapper, never()).selectOne(any());
}
@Test
void legacyBlankRoleRootRemainsSuperAdmin() {
AdminAuthSupport support = new AdminAuthSupport(
mock(JwtService.class), mock(AdminUserMapper.class), mock(AuthProperties.class));
assertThat(support.currentRole(user(1L, null, 1, null))).isEqualTo("super_admin");
}
@Test
void explicitNormalRoleIsNotPromotedByLegacyAdminFields() {
AdminAuthSupport support = new AdminAuthSupport(
mock(JwtService.class), mock(AdminUserMapper.class), mock(AuthProperties.class));
assertThat(support.currentRole(user(1L, "normal", 1, null))).isNull();
}
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) {
AdminUserEntity user = new AdminUserEntity();
user.setId(id);
user.setRole(role);
user.setIsAdmin(isAdmin);
user.setCreatedById(createdById);
return user;
}
}
@@ -0,0 +1,91 @@
package com.nanri.aiimage.modules.file.service.oss;
import com.nanri.aiimage.config.OssProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class OssStorageServiceTest {
private OssStorageService storageService;
@BeforeEach
void setUp() {
OssProperties properties = new OssProperties();
properties.setEndpoint("https://oss.aishufu.top");
properties.setPublicEndpoint("https://oss.aishufu.top");
properties.setBucket("nanri-ai-images");
properties.setImageVideoBucket("shufu-video");
properties.setDigitalHumanBucket("nanri-ai-digital-human");
properties.setAccessKeyId("test-access-key");
properties.setAccessKeySecret("test-secret-key");
storageService = new OssStorageService(properties);
}
@Test
void normalizesLegacyPathStyleUrlToCurrentPublicEndpoint() {
assertEquals(
"https://oss.aishufu.top/nanri-ai-images/supply_images/main.jpg",
storageService.normalizeManagedPublicUrl(
"http://47.110.241.161:9000/nanri-ai-images/supply_images/main.jpg"));
}
@Test
void keepsCurrentUrlAndNormalizesVirtualHostUrl() {
String current = "https://oss.aishufu.top/nanri-ai-images/supply_images/main.jpg";
assertEquals(current, storageService.normalizeManagedPublicUrl(current));
assertEquals(
current,
storageService.normalizeManagedPublicUrl(
"https://nanri-ai-images.oss.aishufu.top/supply_images/main.jpg"));
}
@Test
void supportsAllConfiguredBuckets() {
assertEquals(
"https://oss.aishufu.top/shufu-video/result/image_video/demo.mp4",
storageService.normalizeManagedPublicUrl(
"http://47.110.241.161:9000/shufu-video/result/image_video/demo.mp4"));
assertEquals(
"https://oss.aishufu.top/nanri-ai-digital-human/digital-human/versions/demo.mp4",
storageService.normalizeManagedPublicUrl(
"https://nanri-ai-digital-human.oss.aishufu.top/digital-human/versions/demo.mp4"));
}
@Test
void leavesExternalImageUrlsUnchanged() {
List<String> externalUrls = List.of(
"https://m.media-amazon.com/nanri-ai-images/main.jpg?size=large",
"https://cbu01.alicdn.com/img/ibank/main.jpg",
"https://lf9-bot-platform-tos-sign.coze.cn/file.jpg?x-expires=1&x-signature=abc",
"https://nanri-ai-images.evil.example/supply_images/main.jpg");
for (String url : externalUrls) {
assertEquals(url, storageService.normalizeManagedPublicUrl(url));
}
}
@Test
void leavesInvalidOrUnmanagedValuesUnchanged() {
List<String> values = List.of(
"http://47.110.241.161:9001/nanri-ai-images/supply_images/main.jpg",
"http://47.110.241.161.evil.example:9000/nanri-ai-images/supply_images/main.jpg",
"http://47.110.241.161:9000/nanri-ai-images/../other-bucket/main.jpg",
"http://47.110.241.161:9000/nanri-ai-images/%2e%2e/other-bucket/main.jpg",
"https://oss.aishufu.top/not-a-bucket/main.jpg",
"https://oss.aishufu.top/nanri-ai-images",
"supply_images/main.jpg",
"/nanri-ai-images/supply_images/main.jpg",
"not a url");
for (String value : values) {
assertEquals(value, storageService.normalizeManagedPublicUrl(value));
}
assertNull(storageService.normalizeManagedPublicUrl(null));
assertEquals(" ", storageService.normalizeManagedPublicUrl(" "));
}
}
@@ -142,6 +142,7 @@ class ImageVideoAsyncTaskServiceTest {
assertEquals("FAILED", task.getStatus());
assertEquals("FAIL", task.getCozeStatus());
assertEquals("model unavailable", task.getErrorMessage());
assertTrue(task.getResultJson().contains("model unavailable"));
verify(archiveService).capturePollResponse(task, cozeResult);
verify(taskMapper).updateById(task);
@@ -294,7 +295,7 @@ class ImageVideoAsyncTaskServiceTest {
task.setSubmittedAt(LocalDateTime.now().minusHours(2));
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
"execute_status", "Success",
"output", "{\"Output\":\"https://example.com/result.mp4\"}")));
"output", "{\"node_status\":\"{}\",\"Output\":\"{\\\"video_url\\\":\\\"https://example.com/result.mp4\\\"}\"}")));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
when(taskMapper.claimWaiting(98L, "server-121")).thenReturn(1);
@@ -309,6 +310,145 @@ class ImageVideoAsyncTaskServiceTest {
assertNotNull(task.getCompletedAt());
}
@Test
void successfulWorkflowWithEmptyOutputVideoUrlIsMarkedFailedEvenWhenInputVideoExists() {
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
ImageVideoArchiveService archiveService = mock(ImageVideoArchiveService.class);
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
taskMapper,
cozeService,
workflowConfigService,
archiveService,
new ObjectMapper(),
Runnable::run,
instanceMetadata("server-121"));
ImageVideoAsyncTaskEntity task = waitingWorkflowTask();
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
"execute_status", "Success",
"video_info", Map.of("video_url", "https://example.com/input-reference.mp4"),
"output", "{\"node_status\":\"{}\",\"Output\":\"{\\\"video_url\\\":\\\"\\\"}\"}")));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
when(taskMapper.claimWaiting(98L, "server-121")).thenReturn(1);
when(taskMapper.selectById(98L)).thenReturn(task);
when(workflowConfigService.imageVideoWorkflowId()).thenReturn("workflow-1");
when(cozeService.getWorkflowResult(1L, "workflow-1", "exec-98")).thenReturn(cozeResult);
service.pollWaitingTasks();
assertEquals("FAILED", task.getStatus());
assertEquals("SUCCESS", task.getCozeStatus());
assertEquals("Coze workflow succeeded but video_url is empty", task.getErrorMessage());
assertNotNull(task.getCompletedAt());
assertTrue(task.getResultJson().contains("video_url"));
verify(archiveService, never()).enrichCompletedTask(any(), any());
verify(taskMapper).updateById(task);
}
@Test
void successfulVoiceListWithNestedBusinessErrorIsMarkedFailed() {
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
ImageVideoArchiveService archiveService = mock(ImageVideoArchiveService.class);
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
taskMapper,
cozeService,
workflowConfigService,
archiveService,
new ObjectMapper(),
Runnable::run,
instanceMetadata("server-121"));
ImageVideoAsyncTaskEntity task = waitingVoiceListTask();
String error = "Query voices failed: invalid API key (status: 1004)";
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
"execute_status", "Success",
"output", "{\"node_status\":\"{}\",\"Output\":\"{\\\"data\\\":{\\\"base_resp\\\":null,\\\"error\\\":\\\"" + error + "\\\",\\\"system_voice\\\":[],\\\"voice_cloning\\\":[]}}\"}")));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
when(taskMapper.claimWaiting(163L, "server-121")).thenReturn(1);
when(taskMapper.selectById(163L)).thenReturn(task);
when(workflowConfigService.voiceListWorkflowId()).thenReturn("voice-list-workflow");
when(cozeService.getWorkflowResult(665L, "voice-list-workflow", "voice-list-exec"))
.thenReturn(cozeResult);
service.pollWaitingTasks();
assertEquals("FAILED", task.getStatus());
assertEquals("SUCCESS", task.getCozeStatus());
assertEquals(error, task.getErrorMessage());
assertTrue(task.getResultJson().contains("status: 1004"));
verify(archiveService, never()).enrichCompletedTask(any(), any());
verify(taskMapper).updateById(task);
}
@Test
void successfulVoiceListWithBlankBusinessErrorRemainsSuccessful() {
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
taskMapper,
cozeService,
workflowConfigService,
mock(ImageVideoArchiveService.class),
new ObjectMapper(),
Runnable::run,
instanceMetadata("server-121"));
ImageVideoAsyncTaskEntity task = waitingVoiceListTask();
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
"execute_status", "Success",
"output", "{\"Output\":\"{\\\"data\\\":{\\\"base_resp\\\":{\\\"status_code\\\":0,\\\"status_msg\\\":\\\"success\\\"},\\\"error\\\":\\\"\\\",\\\"system_voice\\\":[]}}\"}")));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
when(taskMapper.claimWaiting(163L, "server-121")).thenReturn(1);
when(taskMapper.selectById(163L)).thenReturn(task);
when(workflowConfigService.voiceListWorkflowId()).thenReturn("voice-list-workflow");
when(cozeService.getWorkflowResult(665L, "voice-list-workflow", "voice-list-exec"))
.thenReturn(cozeResult);
service.pollWaitingTasks();
assertEquals("SUCCESS", task.getStatus());
assertEquals("SUCCESS", task.getCozeStatus());
assertEquals(null, task.getErrorMessage());
verify(taskMapper).updateById(task);
}
@Test
void successfulVoiceListWithNonzeroBusinessStatusIsMarkedFailed() {
ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class);
ImageVideoCozeService cozeService = mock(ImageVideoCozeService.class);
ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class);
ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService(
taskMapper,
cozeService,
workflowConfigService,
mock(ImageVideoArchiveService.class),
new ObjectMapper(),
Runnable::run,
instanceMetadata("server-121"));
ImageVideoAsyncTaskEntity task = waitingVoiceListTask();
Map<String, Object> cozeResult = Map.of("data", List.of(Map.of(
"execute_status", "Success",
"output", "{\"Output\":\"{\\\"data\\\":{\\\"base_resp\\\":{\\\"status_code\\\":1004,\\\"status_msg\\\":\\\"invalid API key\\\"},\\\"system_voice\\\":[]}}\"}")));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
when(taskMapper.claimWaiting(163L, "server-121")).thenReturn(1);
when(taskMapper.selectById(163L)).thenReturn(task);
when(workflowConfigService.voiceListWorkflowId()).thenReturn("voice-list-workflow");
when(cozeService.getWorkflowResult(665L, "voice-list-workflow", "voice-list-exec"))
.thenReturn(cozeResult);
service.pollWaitingTasks();
assertEquals("FAILED", task.getStatus());
assertEquals("invalid API key (status_code: 1004)", task.getErrorMessage());
verify(taskMapper).updateById(task);
}
private ImageVideoAsyncTaskEntity waitingDouyinTask() {
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
task.setId(79L);
@@ -331,6 +471,15 @@ class ImageVideoAsyncTaskServiceTest {
return task;
}
private ImageVideoAsyncTaskEntity waitingVoiceListTask() {
ImageVideoAsyncTaskEntity task = waitingDouyinTask();
task.setId(163L);
task.setUserId(665L);
task.setTaskType("VOICE_LIST");
task.setCozeExecuteId("voice-list-exec");
return task;
}
private InstanceMetadata instanceMetadata(String instanceId) {
return new InstanceMetadata(instanceId);
}
@@ -0,0 +1,251 @@
package com.nanri.aiimage.modules.permission.controller;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.model.dto.ImageVideoDataPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class PermissionMenuControllerTest {
@Test
void effectivePermissionEndpointAllowsAuthenticatedNormalUserToReadSelf() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
AdminUserEntity currentUser = new AdminUserEntity();
currentUser.setId(9L);
currentUser.setRole("normal");
PermissionMenuItemVo item = new PermissionMenuItemVo();
item.setId(1L);
when(authSupport.requireUser(request)).thenReturn(currentUser);
when(service.getUserColumnPermissions(currentUser, 9L, "app")).thenReturn(List.of(item));
var response = controller.getUserColumnPermissions(request, 9L, "app");
assertThat(response.getData()).extracting(PermissionMenuItemVo::getId).containsExactly(1L);
verify(authSupport).requireUser(request);
verify(authSupport, never()).requireAdmin(request);
}
@Test
void internalProxyAllowsDatabaseBackedNormalUserToReadOwnEffectivePermissions() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
ReflectionTestUtils.setField(controller, "internalToken", "shared-token");
AdminUserEntity currentUser = new AdminUserEntity();
currentUser.setId(9L);
currentUser.setRole("normal");
when(authSupport.requireUser(request)).thenThrow(new BusinessException(401, "未登录"));
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
when(request.getParameter("operatorId")).thenReturn("9");
when(service.requireUserOperator(9L)).thenReturn(currentUser);
when(service.getUserColumnPermissions(currentUser, 9L, "app")).thenReturn(List.of());
controller.getUserColumnPermissions(request, 9L, "app");
verify(service).requireUserOperator(9L);
verify(service, never()).requireAdminOperator(anyLong());
verify(service).getUserColumnPermissions(currentUser, 9L, "app");
}
@Test
void directIdEndpointStillRequiresAdminForSelf() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
BusinessException authFailure = new BusinessException(403, "需要管理员权限");
when(authSupport.requireAdmin(request)).thenThrow(authFailure);
assertThatThrownBy(() -> controller.getUserColumnIds(request, 9L, "app"))
.isSameAs(authFailure);
verify(authSupport, never()).requireUser(request);
verifyNoInteractions(service);
}
@Test
void directIdUpdateForwardsMenuTypeScope() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
AdminUserEntity operator = new AdminUserEntity();
operator.setId(1L);
operator.setRole("super_admin");
UserColumnPermissionUpdateRequest body = new UserColumnPermissionUpdateRequest();
body.setColumnIds(List.of(12L));
when(authSupport.requireAdmin(request)).thenReturn(operator);
controller.updateUserColumnIds(request, 9L, "app", body);
verify(service).updateUserColumnPermissions(eq(operator), eq(9L), eq(body), eq("app"));
}
@Test
void internalProxyUsesSharedTokenAndDatabaseBackedOperator() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
ReflectionTestUtils.setField(controller, "internalToken", "shared-token");
AdminUserEntity operator = new AdminUserEntity();
operator.setId(12L);
operator.setRole("admin");
when(authSupport.requireAdmin(request)).thenThrow(new BusinessException(401, "未登录"));
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
when(request.getParameter("operatorId")).thenReturn("12");
when(service.requireAdminOperator(12L)).thenReturn(operator);
when(service.list("app")).thenReturn(List.of());
controller.listMenus(request, "app");
verify(service).requireAdminOperator(12L);
verify(service).list("app");
verify(request, never()).getParameter("superAdmin");
}
@Test
void internalProxyRejectsMismatchedSharedToken() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
ReflectionTestUtils.setField(controller, "internalToken", "shared-token");
BusinessException authFailure = new BusinessException(401, "未登录");
when(authSupport.requireAdmin(request)).thenThrow(authFailure);
when(request.getHeader("X-Internal-Token")).thenReturn("wrong-token");
assertThatThrownBy(() -> controller.listMenus(request, "app"))
.isSameAs(authFailure);
verify(service, never()).requireAdminOperator(anyLong());
verify(service, never()).list("app");
}
@Test
void internalProxyUsesSharedTokenFileWhenEnvironmentTokenIsUnconfigured(@TempDir Path tempDir) throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
ReflectionTestUtils.setField(controller, "internalToken", "");
Path tokenFile = tempDir.resolve("internal-token");
Files.writeString(tokenFile, "file-token\n");
ReflectionTestUtils.setField(controller, "internalTokenFile", tokenFile.toString());
AdminUserEntity operator = new AdminUserEntity();
operator.setId(12L);
operator.setRole("admin");
when(authSupport.requireAdmin(request)).thenThrow(new BusinessException(401, "未登录"));
when(request.getHeader("X-Internal-Token")).thenReturn("file-token");
when(request.getParameter("operatorId")).thenReturn("12");
when(service.requireAdminOperator(12L)).thenReturn(operator);
when(service.list("admin")).thenReturn(List.of());
controller.listMenus(request, "admin");
verify(service).requireAdminOperator(12L);
verify(service).list("admin");
}
@Test
void internalProxyRejectsMissingSharedTokenFileWhenEnvironmentTokenIsUnconfigured(@TempDir Path tempDir) {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
ReflectionTestUtils.setField(controller, "internalToken", "");
ReflectionTestUtils.setField(controller, "internalTokenFile", tempDir.resolve("missing-token").toString());
BusinessException authFailure = new BusinessException(401, "未登录");
when(authSupport.requireAdmin(request)).thenThrow(authFailure);
when(request.getHeader("X-Internal-Token")).thenReturn("guessed-token");
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
when(request.getParameter("operatorId")).thenReturn("12");
assertThatThrownBy(() -> controller.listMenus(request, "admin"))
.isSameAs(authFailure);
verify(service, never()).requireAdminOperator(anyLong());
verify(service, never()).list("admin");
}
@Test
void configuredInternalTokenTakesPrecedenceOverTokenFile(@TempDir Path tempDir) throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
Path tokenFile = tempDir.resolve("internal-token");
Files.writeString(tokenFile, "file-token\n");
ReflectionTestUtils.setField(controller, "internalToken", "configured-token");
ReflectionTestUtils.setField(controller, "internalTokenFile", tokenFile.toString());
BusinessException authFailure = new BusinessException(401, "未登录");
when(authSupport.requireAdmin(request)).thenThrow(authFailure);
when(request.getHeader("X-Internal-Token")).thenReturn("file-token");
when(request.getParameter("operatorId")).thenReturn("12");
assertThatThrownBy(() -> controller.listMenus(request, "admin"))
.isSameAs(authFailure);
verify(service, never()).requireAdminOperator(anyLong());
verify(service, never()).list("admin");
}
@Test
void imageVideoPermissionEndpointsDelegateAuthenticatedOperator() {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService service = mock(PermissionMenuService.class);
HttpServletRequest request = mock(HttpServletRequest.class);
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
AdminUserEntity operator = new AdminUserEntity();
operator.setId(1L);
operator.setRole("super_admin");
ImageVideoDataPermissionUserVo permissionUser = new ImageVideoDataPermissionUserVo();
permissionUser.setId(20L);
ImageVideoDataPermissionUpdateRequest body = new ImageVideoDataPermissionUpdateRequest();
body.setUserIds(List.of(20L));
when(authSupport.requireAdmin(request)).thenReturn(operator);
when(service.listImageVideoDataPermissionUsers(operator)).thenReturn(List.of(permissionUser));
when(service.updateImageVideoDataPermissionUsers(operator, List.of(20L))).thenReturn(1);
var listResponse = controller.listImageVideoDataPermissionUsers(request);
var updateResponse = controller.updateImageVideoDataPermissionUsers(request, body);
assertThat(listResponse.getData()).containsExactly(permissionUser);
assertThat(updateResponse.getData()).isEqualTo(1);
verify(authSupport, times(2)).requireAdmin(request);
verify(service).listImageVideoDataPermissionUsers(operator);
verify(service).updateImageVideoDataPermissionUsers(operator, List.of(20L));
}
}
@@ -1,20 +1,33 @@
package com.nanri.aiimage.modules.permission.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.mapper.PermissionMenuMapper;
import com.nanri.aiimage.modules.permission.mapper.UserColumnPermissionMapper;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuCreateRequest;
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuUpdateRequest;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity;
import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -61,10 +74,505 @@ class PermissionMenuServiceTest {
verify(permissionMapper, times(0)).insert(any(UserColumnPermissionEntity.class));
}
@Test
void expandsDirectParentGrantToDescendantsWithoutPersistingChildren() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(9L, 1L)));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 10),
menu(2L, 1L, "app", 11),
menu(3L, 2L, "app", 12),
menu(4L, null, "app", 13)));
List<PermissionMenuItemVo> effective = service.getUserColumnPermissions(9L, "app");
assertThat(effective).extracting(PermissionMenuItemVo::getId)
.containsExactly(1L, 2L, 3L);
verify(permissionMapper, never()).insert(any(UserColumnPermissionEntity.class));
}
@Test
void directIdEndpointDoesNotReturnExpandedChildren() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(9L, 1L)));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 10),
menu(2L, 1L, "app", 11)));
UserColumnIdsVo direct = service.getUserColumnIds(9L, "app");
assertThat(direct.getColumnIds()).containsExactly(1L);
}
@Test
void leafOnlyEffectivePermissionIncludesRootKeyWithoutGrantingAncestor() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(9L, 2L)));
PermissionMenuEntity root = menu(1L, null, "app", 1);
root.setColumnKey("brand_front_tools");
PermissionMenuEntity leaf = menu(2L, 1L, "app", 2);
leaf.setColumnKey("brand");
when(menuMapper.selectList(any())).thenReturn(List.of(root, leaf));
List<PermissionMenuItemVo> effective = service.getUserColumnPermissions(9L, "app");
assertThat(effective).extracting(PermissionMenuItemVo::getId).containsExactly(2L);
assertThat(effective.getFirst().getRootColumnKey()).isEqualTo("brand_front_tools");
}
@Test
void menuCatalogIncludesRootKeyFromLoadedHierarchy() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
PermissionMenuEntity root = menu(1L, null, "app", 1);
root.setColumnKey("brand_front_tools");
PermissionMenuEntity leaf = menu(2L, 1L, "app", 2);
leaf.setColumnKey("brand");
when(menuMapper.selectList(any())).thenReturn(List.of(root, leaf));
List<PermissionMenuItemVo> result = service.list("app");
assertThat(result).extracting(PermissionMenuItemVo::getId).containsExactly(1L, 2L);
assertThat(result).extracting(PermissionMenuItemVo::getRootColumnKey)
.containsExactly("brand_front_tools", "brand_front_tools");
}
@Test
void superAdminDirectIdEndpointReturnsOnlyPersistedIds() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(1L)).thenReturn(user(1L, "super_admin", 1));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(1L, 2L)));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 1),
menu(2L, 1L, "app", 2)));
UserColumnIdsVo direct = service.getUserColumnIds(1L, "app");
assertThat(direct.getColumnIds()).containsExactly(2L);
}
@Test
void superAdminEffectivePermissionsRemainVirtualFullCatalog() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(1L)).thenReturn(user(1L, "super_admin", 1));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 1),
menu(2L, 1L, "app", 2)));
List<PermissionMenuItemVo> effective = service.getUserColumnPermissions(1L, "app");
assertThat(effective).extracting(PermissionMenuItemVo::getId).containsExactly(1L, 2L);
verify(permissionMapper, never()).selectList(any());
}
@Test
void normalUserCanReadOwnEffectivePermissions() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity currentUser = user(9L, "normal", 0);
when(userMapper.selectById(9L)).thenReturn(currentUser);
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(9L, 1L)));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 10),
menu(2L, 1L, "app", 11)));
List<PermissionMenuItemVo> effective = service.getUserColumnPermissions(currentUser, 9L, "app");
assertThat(effective).extracting(PermissionMenuItemVo::getId)
.containsExactly(1L, 2L);
}
@Test
void normalUserCannotReadAnotherUsersEffectivePermissions() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity currentUser = user(9L, "normal", 0);
when(userMapper.selectById(10L)).thenReturn(user(10L, "normal", 0));
assertThatThrownBy(() -> service.getUserColumnPermissions(currentUser, 10L, "app"))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("管理员权限");
verify(permissionMapper, never()).selectList(any());
}
@Test
void rejectsParentFromAnotherMenuType() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
PermissionMenuEntity parent = menu(7L, null, "admin", 1);
when(menuMapper.selectById(7L)).thenReturn(parent);
PermissionMenuCreateRequest request = createRequest(7L, "app");
assertThatThrownBy(() -> service.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("同一类型");
verify(menuMapper, never()).insert(any(PermissionMenuEntity.class));
}
@Test
void rejectsParentCycleWhenUpdatingMenu() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
PermissionMenuEntity current = menu(1L, null, "app", 1);
PermissionMenuEntity parent = menu(2L, 3L, "app", 2);
PermissionMenuEntity ancestor = menu(3L, 1L, "app", 3);
when(menuMapper.selectById(1L)).thenReturn(current);
when(menuMapper.selectById(2L)).thenReturn(parent);
when(menuMapper.selectById(3L)).thenReturn(ancestor);
PermissionMenuUpdateRequest request = new PermissionMenuUpdateRequest();
request.setName("changed");
request.setColumnKey("changed");
request.setMenuType("app");
request.setRoutePath("changed");
request.setParentId(2L);
assertThatThrownBy(() -> service.update(1L, request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("自己的子级");
verify(menuMapper, never()).updateById(any(PermissionMenuEntity.class));
}
@Test
void rejectsChangingMenuTypeWhileChildrenRemain() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(menuMapper.selectById(1L)).thenReturn(menu(1L, null, "app", 1));
when(menuMapper.selectCount(any())).thenReturn(1L);
PermissionMenuUpdateRequest request = new PermissionMenuUpdateRequest();
request.setName("changed");
request.setColumnKey("changed");
request.setMenuType("admin");
request.setRoutePath("changed");
assertThatThrownBy(() -> service.update(1L, request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("子菜单");
verify(menuMapper, never()).updateById(any(PermissionMenuEntity.class));
}
@Test
void refusesDeletingParentMenuUntilChildrenAreRemoved() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(menuMapper.selectById(1L)).thenReturn(menu(1L, null, "app", 1));
when(menuMapper.selectCount(any())).thenReturn(1L);
assertThatThrownBy(() -> service.delete(1L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("子菜单");
verify(menuMapper, never()).deleteById(any(Long.class));
verify(permissionMapper, never()).delete(any());
}
@Test
void ordinaryAdminCannotAssignMenuOutsideOwnEffectiveSet() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(10L, "admin", 1);
AdminUserEntity target = user(20L, "normal", 0);
target.setCreatedById(10L);
when(userMapper.selectById(20L)).thenReturn(target);
when(userMapper.selectById(10L)).thenReturn(operator);
when(userMapper.selectOne(any())).thenReturn(null);
when(menuMapper.selectCount(any())).thenReturn(1L);
when(menuMapper.selectOne(any())).thenReturn(null);
when(menuMapper.selectList(any())).thenReturn(List.of(menu(1L, null, "app", 1)));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(10L, 1L)));
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(2L));
assertThatThrownBy(() -> service.updateUserColumnPermissions(operator, 20L, request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("自己已有");
verify(permissionMapper, never()).delete(any());
}
@Test
void ordinaryAdminCanAssignDescendantGrantedThroughParent() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(10L, "admin", 1);
AdminUserEntity target = user(20L, "normal", 0);
target.setCreatedById(10L);
when(userMapper.selectById(20L)).thenReturn(target);
when(userMapper.selectOne(any())).thenReturn(null);
when(menuMapper.selectCount(any())).thenReturn(1L);
when(menuMapper.selectOne(any())).thenReturn(null);
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 1),
menu(2L, 1L, "app", 2)));
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(10L, 1L)));
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(2L));
service.updateUserColumnPermissions(operator, 20L, request);
ArgumentCaptor<UserColumnPermissionEntity> captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
verify(permissionMapper).insert(captor.capture());
assertThat(captor.getValue().getColumnId()).isEqualTo(2L);
}
@Test
void ordinaryAdminReplacementPreservesDirectGrantsOutsideEffectiveScope() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(10L, "admin", 1);
AdminUserEntity target = user(20L, "normal", 0);
target.setCreatedById(10L);
when(userMapper.selectById(20L)).thenReturn(target);
when(menuMapper.selectCount(any())).thenReturn(1L);
when(menuMapper.selectOne(any())).thenReturn(null);
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "app", 1),
menu(2L, null, "app", 2)));
when(permissionMapper.selectList(any()))
.thenReturn(List.of(grant(10L, 1L)), List.of(grant(20L, 2L)));
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(1L));
service.updateUserColumnPermissions(operator, 20L, request);
ArgumentCaptor<UserColumnPermissionEntity> inserted =
ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
verify(permissionMapper, times(2)).insert(inserted.capture());
assertThat(inserted.getAllValues())
.extracting(UserColumnPermissionEntity::getColumnId)
.containsExactly(1L, 2L);
}
@Test
void replacementStoresOnlyDirectRequestedParentId() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0));
when(menuMapper.selectCount(any())).thenReturn(1L);
when(menuMapper.selectOne(any())).thenReturn(null);
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(1L));
service.updateUserColumnPermissions(9L, request);
ArgumentCaptor<UserColumnPermissionEntity> captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
verify(permissionMapper).insert(captor.capture());
assertThat(captor.getValue().getColumnId()).isEqualTo(1L);
}
@Test
void appScopedReplacementDeletesOnlyAppDirectGrants() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(1L, "super_admin", 1);
when(userMapper.selectById(9L)).thenReturn(user(9L, "normal", 0));
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(11L, null, "app", 1),
menu(12L, 11L, "app", 2)));
when(menuMapper.selectOne(any())).thenReturn(null);
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(12L));
service.updateUserColumnPermissions(operator, 9L, request, PermissionMenuService.MENU_TYPE_APP);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
UserColumnPermissionEntity.class);
ArgumentCaptor<LambdaUpdateWrapper<UserColumnPermissionEntity>> deleted =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(permissionMapper).delete(deleted.capture());
assertThat(deleted.getValue().getSqlSegment()).contains("column_id", "IN");
assertThat(deleted.getValue().getParamNameValuePairs().values())
.contains(9L, 11L, 12L);
ArgumentCaptor<UserColumnPermissionEntity> inserted =
ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
verify(permissionMapper).insert(inserted.capture());
assertThat(inserted.getValue().getColumnId()).isEqualTo(12L);
}
@Test
void explicitLowestIdAdminCannotManageImageVideoDataPermissions() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity explicitAdmin = user(1L, "admin", 1);
assertThatThrownBy(() -> service.listImageVideoDataPermissionUsers(explicitAdmin))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("超级管理员");
assertThatThrownBy(() -> service.updateImageVideoDataPermissionUsers(explicitAdmin, List.of(2L)))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("超级管理员");
verify(menuMapper, never()).selectOne(any());
verify(permissionMapper, never()).delete(any());
}
@Test
void explicitNormalRoleCannotActAsInternalAdminDespiteLegacyFlag() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
when(userMapper.selectById(1L)).thenReturn(user(1L, "normal", 1));
assertThatThrownBy(() -> service.requireAdminOperator(1L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("管理员权限");
}
@Test
void superAdminCanListImageVideoDataPermissionUsers() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(99L, "super_admin", 1);
AdminUserEntity explicitAdmin = user(1L, "admin", 1);
explicitAdmin.setUsername("admin");
AdminUserEntity normal = user(2L, "normal", 0);
normal.setUsername("normal");
when(menuMapper.selectOne(any())).thenReturn(imageVideoDataPermission());
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(1L, 75L)));
when(userMapper.selectList(any())).thenReturn(List.of(operator, explicitAdmin, normal));
List<ImageVideoDataPermissionUserVo> result = service.listImageVideoDataPermissionUsers(operator);
assertThat(result).extracting(ImageVideoDataPermissionUserVo::getId).containsExactly(1L, 2L);
assertThat(result.get(0).isGranted()).isTrue();
assertThat(result.get(1).isGranted()).isFalse();
}
@Test
void imageVideoUpdateDeletesOnlyProtectedColumnGrants() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(99L, "super_admin", 1);
AdminUserEntity explicitAdmin = user(1L, "admin", 1);
AdminUserEntity normal = user(2L, "normal", 0);
when(menuMapper.selectOne(any())).thenReturn(imageVideoDataPermission());
when(userMapper.selectList(any())).thenReturn(List.of(operator, explicitAdmin, normal));
int grantedCount = service.updateImageVideoDataPermissionUsers(operator, List.of(2L));
assertThat(grantedCount).isEqualTo(1);
verify(permissionMapper).deleteByMap(Map.of("column_id", 75L));
ArgumentCaptor<UserColumnPermissionEntity> inserted =
ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
verify(permissionMapper).insert(inserted.capture());
assertThat(inserted.getValue().getUserId()).isEqualTo(2L);
assertThat(inserted.getValue().getColumnId()).isEqualTo(75L);
}
private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) {
PermissionMenuCreateRequest request = new PermissionMenuCreateRequest();
request.setName("child");
request.setColumnKey("child");
request.setMenuType(menuType);
request.setRoutePath("child");
request.setParentId(parentId);
return request;
}
private PermissionMenuEntity menu(Long id, Long parentId, String menuType, int sortOrder) {
PermissionMenuEntity entity = new PermissionMenuEntity();
entity.setId(id);
entity.setParentId(parentId);
entity.setMenuType(menuType);
entity.setColumnKey("menu-" + id);
entity.setRoutePath("route-" + id);
entity.setSortOrder(sortOrder);
return entity;
}
private UserColumnPermissionEntity grant(Long userId, Long columnId) {
UserColumnPermissionEntity entity = new UserColumnPermissionEntity();
entity.setUserId(userId);
entity.setColumnId(columnId);
return entity;
}
private AdminUserEntity user(Long id, String role, int isAdmin) {
AdminUserEntity entity = new AdminUserEntity();
entity.setId(id);
entity.setRole(role);
entity.setIsAdmin(isAdmin);
return entity;
}
private PermissionMenuEntity imageVideoPermission() {
PermissionMenuEntity entity = new PermissionMenuEntity();
entity.setId(75L);
entity.setColumnKey("admin_image_video_tasks");
return entity;
}
private PermissionMenuEntity imageVideoDataPermission() {
PermissionMenuEntity entity = new PermissionMenuEntity();
entity.setId(75L);
entity.setColumnKey("admin_image_video_task_data");
return entity;
}
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.publish.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -19,10 +20,15 @@ import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity;
import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
@@ -32,6 +38,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
@@ -39,7 +46,12 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -68,6 +80,8 @@ class PublishTaskServiceTest {
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishFileEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@Mock private LocalFileStorageService localFileStorageService;
@@ -77,17 +91,26 @@ class PublishTaskServiceTest {
@Mock private PublishItemMapper publishItemMapper;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskFileJobService taskFileJobService;
@Mock private TaskDistributedLockService taskDistributedLockService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private OssStorageService ossStorageService;
@Mock private ObjectMapper objectMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private TransactionTemplate transactionTemplate;
@Mock private InstanceMetadata instanceMetadata;
@InjectMocks private PublishTaskService service;
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
private int nextPayloadId;
@BeforeEach
void executeTransactionsInline() {
configureChunkStorage();
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
lenient().when(transactionTemplate.execute(any())).thenAnswer(invocation -> {
TransactionCallback<?> callback = invocation.getArgument(0);
@@ -159,6 +182,62 @@ class PublishTaskServiceTest {
verifyNoInteractions(publishItemMapper);
}
@Test
void failedTaskOwnedByAnotherInstanceCanBeDeletedLocally() throws Exception {
long taskId = 110L;
FileTaskEntity task = task(taskId, 7L, "FAILED");
task.setResultJson("{\"ownerInstanceId\":\"instance-b\"}");
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(fileResultMapper.selectList(any())).thenReturn(List.of());
service.deleteTask(taskId, 7L);
verify(taskFileJobService).deleteTaskJobs(taskId, PublishTaskService.MODULE_TYPE);
verify(publishItemMapper).delete(any());
verify(publishFileMapper).delete(any());
verify(fileResultMapper).delete(any());
verify(fileTaskMapper).deleteById(taskId);
verify(objectMapper, never()).readTree(any(String.class));
}
@Test
void runningTaskOwnedByAnotherInstanceStillRequiresRouting() throws Exception {
long taskId = 111L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
task.setResultJson("{\"ownerInstanceId\":\"instance-b\"}");
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(objectMapper.readTree(task.getResultJson())).thenReturn(
new ObjectMapper().readTree(task.getResultJson()));
TaskOwnerMismatchException error = assertThrows(TaskOwnerMismatchException.class,
() -> service.deleteTask(taskId, 7L));
assertEquals(taskId, error.getTaskId());
assertEquals("instance-b", error.getOwnerInstanceId());
verifyNoInteractions(taskFileJobService, publishItemMapper, publishFileMapper, fileResultMapper);
verify(fileTaskMapper, never()).deleteById(taskId);
}
@Test
void historyForFailedTaskOwnedByAnotherInstanceCanBeDeletedLocally() throws Exception {
long taskId = 112L;
long resultId = 212L;
FileTaskEntity task = task(taskId, 7L, "FAILED");
task.setResultJson("{\"ownerInstanceId\":\"instance-b\"}");
FileResultEntity result = new FileResultEntity();
result.setId(resultId);
result.setTaskId(taskId);
result.setModuleType(PublishTaskService.MODULE_TYPE);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
service.deleteHistory(resultId, 7L);
verify(fileTaskMapper).deleteById(taskId);
verify(objectMapper, never()).readTree(any(String.class));
}
@Test
void resultCallbackRejectsAnotherUserBeforeReadingOrReplacingRows() {
long taskId = 101L;
@@ -258,6 +337,242 @@ class PublishTaskServiceTest {
verify(lock).close();
}
@Test
void resultCallbackMergesOutOfOrderChunksOnlyAfterAllChunksArrive() {
long taskId = 113L;
long fileId = 213L;
long resultId = 313L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "分片.xlsx");
FileResultEntity result = result(taskId, resultId);
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result);
when(publishItemMapper.selectCount(any())).thenReturn(2L);
service.submitResult(taskId, chunkResultRequest(7L, fileId, 2, 2, List.of(row("2"))));
assertEquals("RUNNING", file.getStatus());
assertEquals(1, storedChunks.size());
assertEquals(1, storedScopes.getFirst().getReceivedChunkCount());
assertEquals(0, storedScopes.getFirst().getCompleted());
verify(publishItemMapper, never()).delete(any());
verify(taskFileJobService, never()).enqueueAssembleResult(any(), any(), any(), any());
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
assertEquals("SUCCESS", file.getStatus());
assertEquals(2, storedChunks.size());
assertEquals(2, storedScopes.getFirst().getReceivedChunkCount());
assertEquals(1, storedScopes.getFirst().getCompleted());
org.mockito.ArgumentCaptor<List<PublishItemEntity>> rowsCaptor =
org.mockito.ArgumentCaptor.forClass(List.class);
verify(publishItemMapper).insertBatch(rowsCaptor.capture());
assertEquals(List.of("1", "2"), rowsCaptor.getValue().stream()
.map(PublishItemEntity::getSourceId)
.toList());
verify(taskFileJobService).enqueueAssembleResult(
taskId, PublishTaskService.MODULE_TYPE, resultId,
"task:" + taskId + ":owner:instance-a");
}
@Test
void identicalResultChunkRetryIsIdempotent() {
long taskId = 114L;
long fileId = 214L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "重试.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
PublishSubmitResultRequest request = chunkResultRequest(7L, fileId, 1, 2, List.of(row("1")));
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result(taskId, 314L));
service.submitResult(taskId, request);
service.submitResult(taskId, request);
assertEquals(1, storedChunks.size());
assertEquals(1, rustfsPayloads.size());
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
verify(publishItemMapper, never()).delete(any());
}
@Test
void resultChunkRetryWithDifferentContentIsRejected() {
long taskId = 115L;
long fileId = 215L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "冲突.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result(taskId, 315L));
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
BusinessException error = assertThrows(BusinessException.class, () -> service.submitResult(
taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("different")))));
assertTrue(error.getMessage().contains("不同内容"));
assertEquals(1, storedChunks.size());
assertEquals(1, rustfsPayloads.size());
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
}
@Test
void resultChunksRejectChangedChunkTotal() {
long taskId = 116L;
long fileId = 216L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "总片数冲突.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result(taskId, 316L));
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
BusinessException error = assertThrows(BusinessException.class, () -> service.submitResult(
taskId, chunkResultRequest(7L, fileId, 2, 3, List.of(row("2")))));
assertTrue(error.getMessage().contains("chunk_total"));
assertEquals(1, storedChunks.size());
}
@Test
void resultChunkRejectsCallbackWhenRustfsIsUnavailable() {
long taskId = 117L;
long fileId = 217L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "RustFS未配置.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(false);
BusinessException error = assertThrows(BusinessException.class, () -> service.submitResult(
taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1")))));
assertTrue(error.getMessage().contains("RustFS 未配置"));
assertEquals(0, storedChunks.size());
verify(publishItemMapper, never()).delete(any());
}
@Test
void resultChunkRejectsLocalFallbackPointer() {
long taskId = 118L;
long fileId = 218L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "本地回退.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
String localPointer = "local:instance-a/publish/chunk-1";
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(transientPayloadStorageService.storeChunkPayloadVersioned(any(), any(), any(), any(), any()))
.thenReturn(localPointer);
when(transientPayloadStorageService.extractPointer(localPointer)).thenReturn(localPointer);
BusinessException error = assertThrows(BusinessException.class, () -> service.submitResult(
taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1")))));
assertTrue(error.getMessage().contains("必须写入 RustFS"));
assertEquals(0, storedChunks.size());
verify(transientPayloadStorageService).deletePayloadIfPresent(localPointer);
}
@Test
void resultChunkDeletesRustfsPayloadWhenMetadataInsertFails() {
long taskId = 120L;
long fileId = 220L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "元数据失败.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(taskChunkMapper.insert(any(TaskChunkEntity.class)))
.thenThrow(new IllegalStateException("database unavailable"));
assertThrows(IllegalStateException.class, () -> service.submitResult(
taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1")))));
assertTrue(rustfsPayloads.isEmpty());
assertTrue(storedChunks.isEmpty());
verify(transientPayloadStorageService).deletePayloadIfPresent(any());
}
@Test
void deletingTaskRemovesPublishChunksAndRustfsPayloads() {
long taskId = 119L;
long fileId = 219L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "待删除.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result(taskId, 319L));
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
String pointer = storedChunks.getFirst().getPayloadJson();
assertEquals(1, storedChunks.size());
assertEquals(1, storedScopes.size());
assertEquals(1, rustfsPayloads.size());
when(fileResultMapper.selectList(any())).thenReturn(List.of());
service.deleteTask(taskId, 7L);
assertTrue(storedChunks.isEmpty());
assertTrue(storedScopes.isEmpty());
assertTrue(rustfsPayloads.isEmpty());
verify(transientPayloadStorageService).deletePayloadIfPresent(pointer);
verify(fileTaskMapper).deleteById(taskId);
}
@Test
void allFailedTaskRemovesPreviouslyUploadedChunksAfterCommit() {
long taskId = 121L;
long fileId = 221L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
PublishFileEntity file = file(taskId, fileId, "RUNNING", "执行失败.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result(taskId, 321L));
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
assertEquals(1, storedChunks.size());
service.submitResult(taskId, errorResultRequest(7L, fileId, "Python 执行失败"));
assertEquals("FAILED", task.getStatus());
assertEquals("FAILED", file.getStatus());
assertTrue(storedChunks.isEmpty());
assertTrue(storedScopes.isEmpty());
assertTrue(rustfsPayloads.isEmpty());
}
@Test
void activateFileRejectsSecondRunningFileWhileHoldingTaskLock() {
long taskId = 104L;
@@ -346,6 +661,53 @@ class PublishTaskServiceTest {
verify(lock).close();
}
@Test
void staleTaskRecoversReceivedChunksAndEnqueuesAssembly() {
long taskId = 118L;
long fileId = 218L;
long resultId = 318L;
FileTaskEntity task = task(taskId, 7L, "RUNNING");
task.setSourceFileCount(1);
task.setUpdatedAt(LocalDateTime.now().minusHours(1));
PublishFileEntity file = file(taskId, fileId, "RUNNING", "部分结果.xlsx");
file.setTotalRows(2);
FileResultEntity result = result(taskId, resultId);
TaskDistributedLockService.LockHandle submitLock = mock(TaskDistributedLockService.LockHandle.class);
TaskDistributedLockService.LockHandle staleLock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(submitLock);
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId, 0L)).thenReturn(staleLock);
when(fileTaskMapper.selectList(any())).thenReturn(List.of(task));
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
when(publishFileMapper.selectById(fileId)).thenReturn(file);
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
when(fileResultMapper.selectOne(any())).thenReturn(result);
when(taskFileJobService.countUnfinishedAssembleJobs(taskId, PublishTaskService.MODULE_TYPE))
.thenReturn(0L);
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
task.setUpdatedAt(LocalDateTime.now().minusHours(1));
service.failStaleTasks();
assertEquals("SUCCESS", file.getStatus());
assertEquals(1, file.getTotalRows());
assertEquals(1, file.getProcessedRows());
assertTrue(file.getErrorMessage().contains("已保留 1 行"));
assertEquals(1, task.getSuccessFileCount());
assertEquals(0, task.getFailedFileCount());
org.mockito.ArgumentCaptor<List<PublishItemEntity>> rowsCaptor =
org.mockito.ArgumentCaptor.forClass(List.class);
verify(publishItemMapper).insertBatch(rowsCaptor.capture());
assertEquals(List.of("1"), rowsCaptor.getValue().stream()
.map(PublishItemEntity::getSourceId)
.toList());
verify(taskFileJobService).enqueueAssembleResult(
taskId, PublishTaskService.MODULE_TYPE, resultId,
"task:" + taskId + ":owner:instance-a");
verify(submitLock).close();
verify(staleLock).close();
}
@Test
void staleTaskWithoutSuccessfulFilesFailsTaskAndResult() {
long taskId = 107L;
@@ -386,6 +748,140 @@ class PublishTaskServiceTest {
verify(lock).close();
}
private void configureChunkStorage() {
storedChunks.clear();
storedScopes.clear();
rustfsPayloads.clear();
nextPayloadId = 0;
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
any(), any(), any(), any(), any())).thenAnswer(invocation -> {
String pointer = "rustfs:test/publish/chunk-" + (++nextPayloadId);
rustfsPayloads.put(pointer, invocation.getArgument(4));
return pointer;
});
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
String value = invocation.getArgument(0);
return value != null && value.startsWith("rustfs:") ? value : null;
});
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
String pointer = invocation.getArgument(0);
String payload = rustfsPayloads.get(pointer);
if (payload == null) {
throw new IllegalStateException("missing test RustFS payload: " + pointer);
}
return payload;
});
lenient().doAnswer(invocation -> {
rustfsPayloads.remove(invocation.getArgument(0));
return null;
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
TaskChunkEntity chunk = invocation.getArgument(0);
chunk.setId((long) storedChunks.size() + 1L);
storedChunks.add(chunk);
return 1;
});
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
Integer chunkIndex = queryInteger(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
.findFirst()
.orElse(null);
});
lenient().when(taskChunkMapper.selectCount(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
.count();
});
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
.toList();
});
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
int before = storedChunks.size();
storedChunks.removeIf(chunk -> taskId == null || Objects.equals(taskId, chunk.getTaskId()));
return before - storedChunks.size();
});
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
TaskScopeStateEntity scope = invocation.getArgument(0);
scope.setId((long) storedScopes.size() + 1L);
storedScopes.add(scope);
return 1;
});
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedScopes.stream()
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
.filter(scope -> Objects.equals(scopeHash, scope.getScopeHash()))
.findFirst()
.orElse(null);
});
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.delete(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
int before = storedScopes.size();
storedScopes.removeIf(scope -> taskId == null || Objects.equals(taskId, scope.getTaskId()));
return before - storedScopes.size();
});
}
private boolean matchesChunk(TaskChunkEntity chunk,
Long taskId,
String scopeHash,
Integer chunkIndex) {
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
}
private Long queryLong(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(Long.class::isInstance)
.map(Long.class::cast)
.findFirst()
.orElse(null);
}
private Integer queryInteger(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(Integer.class::isInstance)
.map(Integer.class::cast)
.findFirst()
.orElse(null);
}
private String queryScopeHash(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.filter(value -> value.length() == 64)
.findFirst()
.orElse(null);
}
private FileTaskEntity task(long taskId, long userId, String status) {
FileTaskEntity task = new FileTaskEntity();
task.setId(taskId);
@@ -407,11 +903,30 @@ class PublishTaskServiceTest {
return file;
}
private FileResultEntity result(long taskId, long resultId) {
FileResultEntity result = new FileResultEntity();
result.setId(resultId);
result.setTaskId(taskId);
result.setModuleType(PublishTaskService.MODULE_TYPE);
result.setSuccess(0);
return result;
}
private PublishSubmitResultRequest resultRequest(long userId,
long fileId,
List<PublishRowDto> rows) {
long fileId,
List<PublishRowDto> rows) {
return chunkResultRequest(userId, fileId, null, null, rows);
}
private PublishSubmitResultRequest chunkResultRequest(long userId,
long fileId,
Integer chunkIndex,
Integer chunkTotal,
List<PublishRowDto> rows) {
PublishResultFileDto file = new PublishResultFileDto();
file.setFileId(fileId);
file.setChunkIndex(chunkIndex);
file.setChunkTotal(chunkTotal);
file.setRows(rows);
PublishSubmitResultRequest request = new PublishSubmitResultRequest();
request.setUserId(userId);
@@ -419,6 +934,18 @@ class PublishTaskServiceTest {
return request;
}
private PublishSubmitResultRequest errorResultRequest(long userId,
long fileId,
String error) {
PublishResultFileDto file = new PublishResultFileDto();
file.setFileId(fileId);
file.setError(error);
PublishSubmitResultRequest request = new PublishSubmitResultRequest();
request.setUserId(userId);
request.setFiles(List.of(file));
return request;
}
private PublishRowDto row(String sourceId) {
PublishRowDto row = new PublishRowDto();
row.setSourceId(sourceId);
@@ -0,0 +1,56 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShopDataCrawlExcelAssemblyServiceTest {
@TempDir Path tempDir;
@Test
void writesRowsIntoStrictTemplateWithoutChangingSheetOrderOrHeaders() throws Exception {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate("2026-07-25");
row.setAsin("B012345678");
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
country.setCountry("UK");
country.setItems(List.of(row));
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(true);
item.setCountryResults(List.of(country));
File output = tempDir.resolve("result.xlsx").toFile();
new ShopDataCrawlExcelAssemblyService().writeWorkbook(output, List.of(item));
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(ShopDataCrawlExcelAssemblyService.SHEETS,
java.util.stream.IntStream.range(0, workbook.getNumberOfSheets())
.mapToObj(i -> workbook.getSheetAt(i).getSheetName()).toList());
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
for (int columnIndex = 0; columnIndex < ShopDataCrawlExcelAssemblyService.HEADERS.size(); columnIndex++) {
assertEquals(ShopDataCrawlExcelAssemblyService.HEADERS.get(columnIndex),
workbook.getSheetAt(i).getRow(0).getCell(columnIndex).getStringCellValue());
}
}
assertEquals("2026-07-25", workbook.getSheet("英国").getRow(1).getCell(0).getStringCellValue());
assertEquals("B012345678", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue());
assertEquals(0, workbook.getSheet("德国").getLastRowNum());
}
}
}
@@ -0,0 +1,39 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ShopDataCrawlResolveServiceTest {
@Mock com.nanri.aiimage.modules.shopdatacrawl.mapper.ShopDataCrawlShopCandidateMapper candidateMapper;
@Mock com.nanri.aiimage.modules.shopdatacrawl.mapper.ShopDataCrawlCountryPrefMapper countryPrefMapper;
@Mock com.fasterxml.jackson.databind.ObjectMapper objectMapper;
@Mock ZiniaoShopSwitchService ziniaoShopSwitchService;
@Mock ShopManageService shopManageService;
@InjectMocks ShopDataCrawlResolveService service;
@Test
void requiresManagedShopBeforeLookingUpZiniaoIndex() {
ZiniaoShopMatchResultVo match = new ZiniaoShopMatchResultVo();
match.setMatched(true);
when(ziniaoShopSwitchService.normalizeShopName(" Demo ")).thenReturn("Demo");
when(ziniaoShopSwitchService.findIndexedStoreByName("Demo", false)).thenReturn(match);
assertSame(match, service.requireMatchedShop(" Demo "));
var order = inOrder(shopManageService, ziniaoShopSwitchService);
order.verify(ziniaoShopSwitchService).normalizeShopName(" Demo ");
order.verify(shopManageService).requireShopByName("Demo");
order.verify(ziniaoShopSwitchService).findIndexedStoreByName("Demo", false);
}
}
@@ -0,0 +1,539 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ShopDataCrawlTaskServiceChunkTest {
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
private static final String SHOP_NAME = "Demo Shop";
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
@Mock private OssStorageService ossStorageService;
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
@Mock private TaskPressureProperties taskPressureProperties;
@Mock private TaskFileJobService taskFileJobService;
@Mock private TaskResultItemService taskResultItemService;
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TaskDistributedLockService taskDistributedLockService;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private InstanceMetadata instanceMetadata;
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ShopDataCrawlTaskService service;
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
private FileTaskEntity task;
private FileResultEntity result;
private int nextPayloadId;
@BeforeEach
void configureStorage() {
service = new ShopDataCrawlTaskService(
fileTaskMapper,
fileResultMapper,
shopDataCrawlResolveService,
excelAssemblyService,
taskCacheService,
ossStorageService,
ziniaoShopSwitchService,
objectMapper,
taskPressureProperties,
taskFileJobService,
taskResultItemService,
taskProgressSnapshotService,
taskDistributedLockService,
taskChunkMapper,
taskScopeStateMapper,
transientPayloadStorageService,
instanceMetadata);
storedChunks.clear();
storedScopes.clear();
rustfsPayloads.clear();
nextPayloadId = 0;
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any())).thenReturn(List.of());
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
lenient().when(excelAssemblyService.countRows(any())).thenReturn(1);
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
Long taskId = invocation.getArgument(0);
return task != null && Objects.equals(taskId, task.getId()) ? task : null;
});
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
lenient().when(fileResultMapper.selectById(anyLong())).thenAnswer(invocation -> {
Long resultId = invocation.getArgument(0);
return result != null && Objects.equals(resultId, result.getId()) ? result : null;
});
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<FileResultEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
return result != null && Objects.equals(taskId, result.getTaskId()) ? List.of(result) : List.of();
});
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenReturn(1);
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
String value = invocation.getArgument(0);
return value == null ? "" : value.trim();
});
configureTransientPayloadStorage();
configureChunkMapper();
configureScopeMapper();
}
@Test
void completesChunkedResultWhenSecondChunkArrivesBeforeFirstChunk() {
givenRunningTask(101L, 201L);
service.submitResult(task.getId(), request(chunk(2, 2, "UK", row("2026-07-26", "B002"))));
assertEquals(-1, result.getSuccess());
assertEquals(1, storedChunks.size());
verify(taskFileJobService, never()).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
assertEquals(1, result.getSuccess());
assertEquals(2, storedChunks.size());
assertTrue(task.getResultJson().indexOf("B001") < task.getResultJson().indexOf("B002"));
verify(taskFileJobService).enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(),
"task:" + task.getId() + ":owner:instance-a");
}
@Test
void identicalResultChunkRetryIsIdempotent() {
givenRunningTask(102L, 202L);
ShopDataCrawlSubmitResultRequest request = request(chunk(1, 2, "DE", row("2026-07-25", "B001")));
service.submitResult(task.getId(), request);
service.submitResult(task.getId(), request);
assertEquals(1, storedChunks.size());
assertEquals(1, rustfsPayloads.size());
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
assertEquals(-1, result.getSuccess());
}
@Test
void resultChunkRetryWithDifferentContentIsRejected() {
givenRunningTask(103L, 203L);
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
BusinessException error = assertThrows(BusinessException.class, () ->
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B099")))));
assertTrue(error.getMessage().contains("不同内容"));
assertEquals(1, storedChunks.size());
assertEquals(1, rustfsPayloads.size());
}
@Test
void resultChunksRejectChangedChunkTotal() {
givenRunningTask(104L, 204L);
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
BusinessException error = assertThrows(BusinessException.class, () ->
service.submitResult(task.getId(), request(chunk(2, 3, "UK", row("2026-07-26", "B002")))));
assertTrue(error.getMessage().contains("chunk_total"));
assertEquals(1, storedChunks.size());
}
@Test
void missingChunkDoesNotGenerateResultFile() {
givenRunningTask(105L, 205L);
service.submitResult(task.getId(), request(chunk(1, 3, "DE", row("2026-07-25", "B001"))));
service.submitResult(task.getId(), request(chunk(3, 3, "UK", row("2026-07-27", "B003"))));
assertEquals(-1, result.getSuccess());
assertEquals("RUNNING", task.getStatus());
assertEquals(2, storedChunks.size());
verify(taskFileJobService, never()).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
}
@Test
void staleCompensationRecoversReceivedResultChunks() {
givenRunningTask(110L, 210L);
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
boolean finalized = service.tryFinalizeTask(task.getId(), true);
assertTrue(finalized);
assertEquals(1, result.getSuccess());
assertTrue(result.getErrorMessage().contains("已保留"));
assertTrue(task.getResultJson().contains("B001"));
assertTrue(task.getResultJson().contains("已保留"));
verify(taskFileJobService).enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(),
"task:" + task.getId() + ":owner:instance-a");
}
@Test
void staleCompensationRecoversLegacyMergedPayload() {
givenRunningTask(111L, 211L);
ShopDataCrawlShopPayloadDto cached = legacyChunk(false, "UK", row("2026-07-26", "B002"));
when(taskCacheService.getAllShopMergedPayload(task.getId())).thenReturn(Map.of(SHOP_NAME, cached));
boolean finalized = service.tryFinalizeTask(task.getId(), true);
assertTrue(finalized);
assertEquals(1, result.getSuccess());
assertTrue(result.getErrorMessage().contains("已保留"));
assertTrue(task.getResultJson().contains("B002"));
verify(taskCacheService).removeShopMergedPayload(task.getId(), SHOP_NAME);
verify(taskFileJobService).enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(),
"task:" + task.getId() + ":owner:instance-a");
}
@Test
void resultChunkRejectsCallbackWhenRustfsIsUnavailable() {
givenRunningTask(106L, 206L);
when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(false);
BusinessException error = assertThrows(BusinessException.class, () ->
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001")))));
assertTrue(error.getMessage().contains("RustFS"));
assertEquals(0, storedChunks.size());
}
@Test
void resultChunkRejectsLocalFallbackPointer() {
givenRunningTask(107L, 207L);
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), anyLong(), anyString(), any(), anyString()))
.thenReturn("local:instance-a/chunk-1");
when(transientPayloadStorageService.extractPointer("local:instance-a/chunk-1"))
.thenReturn("local:instance-a/chunk-1");
BusinessException error = assertThrows(BusinessException.class, () ->
service.submitResult(task.getId(), request(chunk(1, 1, "DE", row("2026-07-25", "B001")))));
assertTrue(error.getMessage().contains("RustFS"));
assertEquals(0, storedChunks.size());
verify(transientPayloadStorageService).deletePayloadIfPresent("local:instance-a/chunk-1");
}
@Test
void legacyShopDoneFalseThenTrueUsesMergedPayloadCache() {
givenRunningTask(108L, 208L);
ShopDataCrawlShopPayloadDto cached = legacyChunk(false, "DE", row("2026-07-25", "B001"));
when(taskCacheService.getShopMergedPayload(task.getId(), SHOP_NAME)).thenReturn(null, cached);
service.submitResult(task.getId(), request(legacyChunk(false, "DE", row("2026-07-25", "B001"))));
assertEquals(-1, result.getSuccess());
verify(taskCacheService).saveShopMergedPayload(eq(task.getId()), eq(SHOP_NAME), any());
service.submitResult(task.getId(), request(legacyChunk(true, "UK", row("2026-07-26", "B002"))));
assertEquals(1, result.getSuccess());
assertTrue(task.getResultJson().contains("B001"));
assertTrue(task.getResultJson().contains("B002"));
assertEquals(0, storedChunks.size());
verify(taskCacheService).removeShopMergedPayload(task.getId(), SHOP_NAME);
}
@Test
void cleanupResultFileJobDeletesTransientChunkObjectsAndRows() {
givenRunningTask(109L, 209L);
service.submitResult(task.getId(), request(chunk(1, 2, "DE", row("2026-07-25", "B001"))));
String storedPayload = storedChunks.get(0).getPayloadJson();
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(task.getId());
job.setModuleType(MODULE_TYPE);
job.setResultId(result.getId());
service.cleanupResultFileJob(job);
assertFalse(rustfsPayloads.containsKey(storedPayload));
assertEquals(0, storedChunks.size());
assertEquals(0, storedScopes.size());
verify(taskChunkMapper).delete(any());
verify(taskScopeStateMapper).delete(any());
}
private void configureTransientPayloadStorage() {
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(
anyString(), anyLong(), anyString(), any(), anyString())).thenAnswer(invocation -> {
String pointer = "rustfs:payload-" + (++nextPayloadId);
rustfsPayloads.put(pointer, invocation.getArgument(4));
return pointer;
});
lenient().when(transientPayloadStorageService.extractPointer(any())).thenAnswer(invocation -> {
String value = invocation.getArgument(0);
if (value == null) {
return null;
}
return value.startsWith("rustfs:") || value.startsWith("local:") || value.startsWith("oss:")
? value : null;
});
lenient().when(transientPayloadStorageService.resolvePayload(any(), any())).thenAnswer(invocation -> {
String pointer = invocation.getArgument(0);
String payload = rustfsPayloads.get(pointer);
if (payload == null) {
throw new IllegalStateException("missing test RustFS payload: " + pointer);
}
return payload;
});
lenient().doAnswer(invocation -> {
rustfsPayloads.remove(invocation.getArgument(0));
return null;
}).when(transientPayloadStorageService).deletePayloadIfPresent(any());
}
private void configureChunkMapper() {
lenient().when(taskChunkMapper.insert(any(TaskChunkEntity.class))).thenAnswer(invocation -> {
TaskChunkEntity chunk = invocation.getArgument(0);
chunk.setId((long) storedChunks.size() + 1L);
storedChunks.add(chunk);
return 1;
});
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
Integer chunkIndex = queryInteger(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, chunkIndex))
.findFirst()
.orElse(null);
});
lenient().when(taskChunkMapper.selectCount(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
.count();
});
lenient().when(taskChunkMapper.selectList(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedChunks.stream()
.filter(chunk -> matchesChunk(chunk, taskId, scopeHash, null))
.sorted(Comparator.comparing(TaskChunkEntity::getChunkIndex))
.toList();
});
lenient().when(taskChunkMapper.delete(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskChunkEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
int before = storedChunks.size();
storedChunks.removeIf(chunk -> taskId == null || Objects.equals(taskId, chunk.getTaskId()));
return before - storedChunks.size();
});
}
private void configureScopeMapper() {
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenAnswer(invocation -> {
TaskScopeStateEntity scope = invocation.getArgument(0);
scope.setId((long) storedScopes.size() + 1L);
storedScopes.add(scope);
return 1;
});
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
String scopeHash = queryScopeHash(query);
return storedScopes.stream()
.filter(scope -> Objects.equals(taskId, scope.getTaskId()))
.filter(scope -> scopeHash == null || Objects.equals(scopeHash, scope.getScopeHash()))
.findFirst()
.orElse(null);
});
lenient().when(taskScopeStateMapper.updateById(any(TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.delete(any())).thenAnswer(invocation -> {
LambdaQueryWrapper<TaskScopeStateEntity> query = invocation.getArgument(0);
Long taskId = queryLong(query);
int before = storedScopes.size();
storedScopes.removeIf(scope -> taskId == null || Objects.equals(taskId, scope.getTaskId()));
return before - storedScopes.size();
});
}
private boolean matchesChunk(TaskChunkEntity chunk, Long taskId, String scopeHash, Integer chunkIndex) {
return (taskId == null || Objects.equals(taskId, chunk.getTaskId()))
&& (scopeHash == null || Objects.equals(scopeHash, chunk.getScopeHash()))
&& (chunkIndex == null || Objects.equals(chunkIndex, chunk.getChunkIndex()));
}
private Long queryLong(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(Long.class::isInstance)
.map(Long.class::cast)
.findFirst()
.orElse(null);
}
private Integer queryInteger(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(Integer.class::isInstance)
.map(Integer.class::cast)
.findFirst()
.orElse(null);
}
private String queryScopeHash(LambdaQueryWrapper<?> query) {
query.getSqlSegment();
return query.getParamNameValuePairs().values().stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.filter(value -> value.length() == 64)
.findFirst()
.orElse(null);
}
private void givenRunningTask(long taskId, long resultId) {
task = new FileTaskEntity();
task.setId(taskId);
task.setUserId(7L);
task.setModuleType(MODULE_TYPE);
task.setStatus("RUNNING");
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
task.setResultJson("[]");
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
result = new FileResultEntity();
result.setId(resultId);
result.setTaskId(taskId);
result.setUserId(7L);
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(SHOP_NAME);
result.setSourceFileUrl("shop-1");
result.setSuccess(-1);
result.setCreatedAt(LocalDateTime.now());
}
private ShopDataCrawlSubmitResultRequest request(ShopDataCrawlShopPayloadDto payload) {
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
request.setShops(List.of(payload));
return request;
}
private ShopDataCrawlShopPayloadDto chunk(int chunkIndex,
int chunkTotal,
String country,
ShopDataCrawlRowDto row) {
ShopDataCrawlShopPayloadDto payload = legacyChunk(false, country, row);
payload.setChunkIndex(chunkIndex);
payload.setChunkTotal(chunkTotal);
return payload;
}
private ShopDataCrawlShopPayloadDto legacyChunk(boolean shopDone,
String country,
ShopDataCrawlRowDto row) {
ShopDataCrawlShopPayloadDto payload = new ShopDataCrawlShopPayloadDto();
payload.setShopName(SHOP_NAME);
payload.setCountryResults(List.of(country(country, row)));
payload.setShopDone(shopDone);
return payload;
}
private ShopDataCrawlCountryResultDto country(String country, ShopDataCrawlRowDto row) {
ShopDataCrawlCountryResultDto result = new ShopDataCrawlCountryResultDto();
result.setCountry(country);
result.setItems(List.of(row));
return result;
}
private ShopDataCrawlRowDto row(String date, String asin) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setInventorySales("10");
row.setSalesRank("20");
row.setPageViews("30");
row.setUnitsSold("40");
row.setPrice("50");
row.setRecommendedOffer("60");
return row;
}
}
@@ -0,0 +1,99 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ShopDataCrawlTaskServiceOwnerTest {
@Mock InstanceMetadata instanceMetadata;
@Mock com.nanri.aiimage.modules.task.mapper.FileTaskMapper fileTaskMapper;
@Mock com.nanri.aiimage.modules.task.mapper.FileResultMapper fileResultMapper;
@Mock ShopDataCrawlResolveService resolveService;
@Mock ShopDataCrawlExcelAssemblyService excelAssemblyService;
@Mock ShopDataCrawlTaskCacheService cacheService;
@Mock com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
@Mock com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService ziniaoShopSwitchService;
@Mock com.nanri.aiimage.config.TaskPressureProperties taskPressureProperties;
@Mock com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@Mock com.nanri.aiimage.modules.task.service.TaskResultItemService taskResultItemService;
@Mock com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
@Mock com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
@Mock com.nanri.aiimage.modules.task.mapper.TaskChunkMapper taskChunkMapper;
@Mock com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper taskScopeStateMapper;
@Mock com.nanri.aiimage.modules.task.service.TransientPayloadStorageService transientPayloadStorageService;
@Spy private final ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks ShopDataCrawlTaskService service;
@Test
void rejectsTaskOwnedByAnotherInstance() {
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
FileTaskEntity task = task("instance-b");
assertThrows(TaskOwnerMismatchException.class,
() -> service.ensureTaskOwnedByCurrentInstance(task, "callback"));
}
@Test
void acceptsTaskOwnedByCurrentInstance() {
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
assertDoesNotThrow(() -> service.ensureTaskOwnedByCurrentInstance(task("instance-a"), "callback"));
}
@Test
void callbackWithoutShopNameBindsOnlyTaskShopAndWrongNameFails() {
var payload = new com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto();
when(ziniaoShopSwitchService.normalizeShopName(null)).thenReturn("");
assertEquals(payload, service.normalizePayloadByShop(java.util.List.of(payload), "Demo").get("Demo"));
var wrong = new com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto();
wrong.setShopName("Wrong");
when(ziniaoShopSwitchService.normalizeShopName("Wrong")).thenReturn("Wrong");
assertThrows(com.nanri.aiimage.common.exception.BusinessException.class,
() -> service.normalizePayloadByShop(java.util.List.of(wrong), "Demo"));
}
@Test
void partialPayloadIsNotCompleteUntilShopDoneIsTrue() {
var payload = new com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto();
assertFalse(service.isShopPayloadComplete(payload));
payload.setShopDone(false);
assertFalse(service.isShopPayloadComplete(payload));
payload.setShopDone(true);
assertTrue(service.isShopPayloadComplete(payload));
}
@Test
@SuppressWarnings("unchecked")
void deletesResultObjectOnlyAfterLastDatabaseReferenceIsGone() {
when(fileResultMapper.selectCount(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class)))
.thenReturn(1L, 0L);
service.deleteResultObjectIfUnreferenced("result/shared.xlsx");
verify(ossStorageService, never()).deleteObject("result/shared.xlsx");
service.deleteResultObjectIfUnreferenced("result/shared.xlsx");
verify(ossStorageService).deleteObject("result/shared.xlsx");
}
private FileTaskEntity task(String owner) {
FileTaskEntity task = new FileTaskEntity();
task.setId(1L);
task.setRequestJson("{\"ownerInstanceId\":\"" + owner + "\"}");
return task;
}
}
@@ -7,6 +7,8 @@ import java.lang.reflect.Field;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SimilarAsinTaskServiceTest {
@@ -50,6 +52,14 @@ class SimilarAsinTaskServiceTest {
assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withVisibleResultData, "", "80%", "", "", ""));
}
@Test
void fileBuildProgressIsTerminalOnlyAfterTaskAndStageComplete() {
assertTrue(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 3, 3));
assertTrue(SimilarAsinTaskService.isTerminalFileBuildProgress("FAILED", 3, 3));
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("RUNNING", 3, 3));
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 2, 3));
}
private int staticIntField(String name) throws Exception {
Field field = SimilarAsinTaskService.class.getDeclaredField(name);
field.setAccessible(true);
@@ -1,5 +1,7 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.OssProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
@@ -24,7 +26,33 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
class SimilarAsinImageEmbedderTest {
// properties=null 时构造函数走 DEFAULT_DOWNLOAD_TIMEOUT_SECONDS / DEFAULT_DOWNLOAD_POOL_SIZE 兜底。
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder(null);
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder(null, createOssStorageService());
private static OssStorageService createOssStorageService() {
OssProperties properties = new OssProperties();
properties.setEndpoint("https://oss.aishufu.top");
properties.setPublicEndpoint("https://oss.aishufu.top");
properties.setBucket("nanri-ai-images");
properties.setImageVideoBucket("shufu-video");
properties.setDigitalHumanBucket("nanri-ai-digital-human");
properties.setAccessKeyId("test-access-key");
properties.setAccessKeySecret("test-secret-key");
return new OssStorageService(properties);
}
@Test
void normalizesLegacyMinioUrlBeforeHttpsValidation() {
String normalized = embedder.normalizeAndValidateDownloadUrl(
"http://47.110.241.161:9000/nanri-ai-images/supply_images/main.jpg");
assertEquals("https://oss.aishufu.top/nanri-ai-images/supply_images/main.jpg", normalized);
}
@Test
void leavesUnmanagedHttpUrlBlocked() {
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> embedder.normalizeAndValidateDownloadUrl("http://example.com/main.jpg"));
}
@Test
void resizeImageProducesThumbnailUnderHardCap() throws Exception {
@@ -13,6 +13,8 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskCacheService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
@@ -55,6 +57,8 @@ class TaskHeartbeatServiceTest {
@Mock private ShopMatchTaskCacheService shopMatchTaskCacheService;
@Mock private PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
@Mock private QueryAsinTaskCacheService queryAsinTaskCacheService;
@Mock private ShopDataCrawlTaskCacheService shopDataCrawlTaskCacheService;
@Mock private ShopDataCrawlTaskService shopDataCrawlTaskService;
@Mock private WithdrawTaskCacheService withdrawTaskCacheService;
@Mock private AppearancePatentTaskCacheService appearancePatentTaskCacheService;
@Mock private SimilarAsinTaskCacheService similarAsinTaskCacheService;
@@ -91,4 +95,27 @@ class TaskHeartbeatServiceTest {
routingBeforeUpdate.verify(fileTaskMapper).update(isNull(), any(LambdaUpdateWrapper.class));
verify(publishTaskService).touchHeartbeat(taskId, request);
}
@Test
@SuppressWarnings("unchecked")
void shopDataCrawlHeartbeatChecksOwnerBeforeDatabaseUpdate() {
long taskId = 20143L;
FileTaskEntity task = new FileTaskEntity();
task.setId(taskId);
task.setModuleType("SHOP_DATA_CRAWL");
task.setStatus("RUNNING");
when(fileTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(task);
when(brandCrawlTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
when(fileTaskMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
TaskHeartbeatVo result = service.heartbeat(taskId, new TaskHeartbeatRequest());
assertTrue(result.isAlive());
InOrder order = inOrder(shopDataCrawlTaskService, fileTaskMapper);
order.verify(shopDataCrawlTaskService)
.ensureTaskOwnedByCurrentInstance(task, "shop data crawl task heartbeat");
order.verify(fileTaskMapper).update(isNull(), any(LambdaUpdateWrapper.class));
verify(shopDataCrawlTaskCacheService).touchTaskHeartbeat(taskId);
verify(shopDataCrawlTaskCacheService).saveTaskCache(task);
}
}
@@ -10,6 +10,7 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
@@ -24,6 +25,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -43,6 +45,7 @@ class TaskResultFileJobWorkerTest {
@Mock private ProductRiskTaskService productRiskTaskService;
@Mock private PublishTaskService publishTaskService;
@Mock private QueryAsinTaskService queryAsinTaskService;
@Mock private ShopDataCrawlTaskService shopDataCrawlTaskService;
@Mock private WithdrawTaskService withdrawTaskService;
@Mock private PatrolDeleteTaskService patrolDeleteTaskService;
@Mock private AppearancePatentTaskService appearancePatentTaskService;
@@ -109,8 +112,10 @@ class TaskResultFileJobWorkerTest {
worker.process(job);
verify(publishTaskService).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, "result/publish/20141.xlsx");
InOrder order = inOrder(publishTaskService, taskFileJobService);
order.verify(publishTaskService).processResultFileJob(job);
order.verify(taskFileJobService).markSuccess(job, "result/publish/20141.xlsx");
order.verify(publishTaskService).cleanupResultFileJob(job);
verifyNoInteractions(taskResultPayloadService);
}
@@ -128,4 +133,56 @@ class TaskResultFileJobWorkerTest {
verifyNoInteractions(taskFileJobService, taskDistributedLockService, publishTaskService);
}
@Test
void shopDataCrawlOwnerJobDelegatesAndCleansPayload() {
long jobId = 13643L;
long taskId = 20143L;
long resultId = 22931L;
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(jobId);
job.setTaskId(taskId);
job.setResultId(resultId);
job.setModuleType("SHOP_DATA_CRAWL");
job.setScopeKey("task:20143:owner:instance-a");
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/shop-data-crawl/20143.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
worker.process(job);
verify(shopDataCrawlTaskService).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, "result/shop-data-crawl/20143.xlsx");
verify(taskResultPayloadService).deleteLatest(taskId, "SHOP_DATA_CRAWL", job.getScopeKey());
verify(shopDataCrawlTaskService).cleanupResultFileJob(job);
}
@Test
void exhaustedShopDataCrawlFileJobFailsOwningTask() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(13644L);
job.setTaskId(20144L);
job.setResultId(22932L);
job.setModuleType("SHOP_DATA_CRAWL");
job.setScopeKey("task:20144:owner:instance-a");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
when(taskFileJobService.markRunning(job.getId())).thenReturn(true);
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(),
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
doThrow(new IllegalStateException("upload failed"))
.when(shopDataCrawlTaskService).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
worker.process(job);
verify(taskFileJobService).markFailed(job, "upload failed");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "upload failed");
}
}