菜单修改优化
This commit is contained in:
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+91
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
+150
-1
@@ -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);
|
||||
}
|
||||
|
||||
+251
@@ -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));
|
||||
}
|
||||
}
|
||||
+508
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+530
-3
@@ -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);
|
||||
|
||||
+56
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+539
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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);
|
||||
|
||||
+29
-1
@@ -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 {
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+59
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user