package com.starry.admin.api; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.common.oss.service.IOssFileService; import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState; import com.starry.admin.modules.clerk.enums.ClerkMediaUsage; import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.media.entity.PlayMediaEntity; import com.starry.admin.modules.media.enums.MediaOwnerType; import com.starry.admin.modules.media.service.IPlayMediaService; import com.starry.admin.modules.weichat.entity.PlayClerkUserLoginResponseVo; import com.starry.admin.modules.weichat.entity.clerk.MediaVo; import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo; import com.starry.admin.modules.weichat.service.WxTokenService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.constant.Constants; import com.starry.common.enums.ClerkReviewState; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.nio.file.Files; import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import javax.imageio.ImageIO; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MvcResult; /** * 覆盖店员端媒资上传与相册审核的关键业务路径(图片/视频 + 删除后不复活)。 */ class WxClerkMediaControllerApiTest extends AbstractApiTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private WxTokenService wxTokenService; @Autowired private IPlayClerkUserInfoService clerkUserInfoService; @Autowired private IPlayMediaService mediaService; @Autowired private IPlayClerkDataReviewInfoService dataReviewInfoService; @Autowired private IPlayClerkMediaAssetService mediaAssetService; @MockBean private IOssFileService ossFileService; @Test void clerkCanUploadImageMediaAndPersistUrl() throws Exception { ensureTenantContext(); String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); String ossUrl = "https://oss.mock/apitest/avatar.png"; when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString())) .thenReturn(ossUrl); BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(image, "png", baos); MockMultipartFile file = new MockMultipartFile( "file", "avatar.png", "image/png", baos.toByteArray()); MvcResult result = mockMvc.perform(multipart("/wx/clerk/media/upload") .file(file) .param("usage", "profile") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT) .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) .contentType(MediaType.MULTIPART_FORM_DATA)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.url").value(ossUrl)) .andExpect(jsonPath("$.data.kind").value("image")) .andExpect(jsonPath("$.data.usage").value("profile")) .andReturn(); String body = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(body); JsonNode data = root.path("data"); String mediaId = data.path("mediaId").asText(null); assertThat(mediaId).isNotBlank(); ensureTenantContext(); PlayMediaEntity persisted = mediaService.getById(mediaId); assertThat(persisted).isNotNull(); assertThat(persisted.getOwnerType()).isEqualTo(MediaOwnerType.CLERK); assertThat(persisted.getOwnerId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); assertThat(persisted.getUrl()).isEqualTo(ossUrl); } @Test void clerkCanUploadVideoMediaAndPersistUrl() throws Exception { ensureTenantContext(); String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); String videoUrl = "https://oss.mock/apitest/video.mp4"; String coverUrl = "https://oss.mock/apitest/video-cover.jpg"; when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString())) .thenReturn(videoUrl, coverUrl); byte[] videoBytes = Files.readAllBytes(Paths.get("/Volumes/main/code/yunpei/sample_data/sample_video.mp4")); MockMultipartFile file = new MockMultipartFile( "file", "sample_video.mp4", "video/mp4", videoBytes); MvcResult result = mockMvc.perform(multipart("/wx/clerk/media/upload") .file(file) .param("usage", "profile") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT) .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) .contentType(MediaType.MULTIPART_FORM_DATA)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.kind").value("video")) .andExpect(jsonPath("$.data.usage").value("profile")) .andReturn(); String body = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(body); JsonNode data = root.path("data"); String mediaId = data.path("mediaId").asText(null); assertThat(mediaId).isNotBlank(); ensureTenantContext(); PlayMediaEntity persisted = mediaService.getById(mediaId); assertThat(persisted).isNotNull(); assertThat(persisted.getKind()).isEqualTo("video"); assertThat(persisted.getUrl()).isEqualTo(videoUrl); assertThat(persisted.getCoverUrl()).isEqualTo(coverUrl); } @Test void aggressiveAlbumLifecycleWithFourMediaAndDeletionReflectedForClerkAndCustomer() throws Exception { ensureTenantContext(); String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString())) .thenReturn( "https://oss.mock/apitest/album-a.png", "https://oss.mock/apitest/album-b.png", "https://oss.mock/apitest/album-c.png", "https://oss.mock/apitest/album-d.png"); String mediaIdA = extractMediaIdFromUpload(buildTinyPng("album-a.png"), clerkToken); String mediaIdB = extractMediaIdFromUpload(buildTinyPng("album-b.png"), clerkToken); String mediaIdC = extractMediaIdFromUpload(buildTinyPng("album-c.png"), clerkToken); String mediaIdD = extractMediaIdFromUpload(buildTinyPng("album-d.png"), clerkToken); List allMediaIds = List.of(mediaIdA, mediaIdB, mediaIdC, mediaIdD); submitAlbumUpdate(allMediaIds, clerkToken); ensureTenantContext(); approveLatestAlbumReview(); List assetsAfterFirstApprove = mediaAssetService .listActiveByUsage(ApiTestDataSeeder.DEFAULT_CLERK_ID, ClerkMediaUsage.PROFILE); assertThat(assetsAfterFirstApprove) .extracting(PlayClerkMediaAssetEntity::getMediaId) .containsAll(allMediaIds); List reviewStatesForNewMedia = assetsAfterFirstApprove.stream() .filter(asset -> allMediaIds.contains(asset.getMediaId())) .map(PlayClerkMediaAssetEntity::getReviewState) .collect(Collectors.toList()); assertThat(reviewStatesForNewMedia).containsOnly(ClerkMediaReviewState.APPROVED.getCode()); List clerkVisibleMediaIdsAfterFirst = assetsAfterFirstApprove.stream() .filter(asset -> !ClerkMediaReviewState.REJECTED.getCode().equals(asset.getReviewState())) .map(PlayClerkMediaAssetEntity::getMediaId) .collect(Collectors.toList()); assertThat(clerkVisibleMediaIdsAfterFirst).containsAll(allMediaIds); PlayClerkUserInfoResultVo customerDetailAfterFirst = clerkUserInfoService.buildCustomerDetail(ApiTestDataSeeder.DEFAULT_CLERK_ID, ""); List customerMediaIdsAfterFirst = customerDetailAfterFirst.getMediaList().stream() .map(MediaVo::getMediaId) .collect(Collectors.toList()); assertThat(customerMediaIdsAfterFirst).containsAll(allMediaIds); List keptMedia = List.of(mediaIdA, mediaIdC); // 第二次提交:只删除与重新排序,不再生成新的资料审核记录,应直接生效 long reviewCountBeforeSecond = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); submitAlbumUpdate(keptMedia, clerkToken); ensureTenantContext(); long reviewCountAfterSecond = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); assertThat(reviewCountAfterSecond) .as("deleting/reordering album should not create another review record") .isEqualTo(reviewCountBeforeSecond); List assetsAfterSecondApprove = mediaAssetService .listActiveByUsage(ApiTestDataSeeder.DEFAULT_CLERK_ID, ClerkMediaUsage.PROFILE); PlayClerkMediaAssetEntity assetA = assetsAfterSecondApprove.stream() .filter(a -> mediaIdA.equals(a.getMediaId())) .max(Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime)) .orElse(null); PlayClerkMediaAssetEntity assetC = assetsAfterSecondApprove.stream() .filter(a -> mediaIdC.equals(a.getMediaId())) .max(Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime)) .orElse(null); assertThat(assetA.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode()); assertThat(assetA.getOrderIndex()).isEqualTo(0); assertThat(assetC.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode()); assertThat(assetC.getOrderIndex()).isEqualTo(1); PlayClerkUserInfoResultVo customerDetailAfterSecond = clerkUserInfoService.buildCustomerDetail(ApiTestDataSeeder.DEFAULT_CLERK_ID, ""); List customerMediaIdsAfterSecond = customerDetailAfterSecond.getMediaList().stream() .map(MediaVo::getMediaId) .collect(Collectors.toList()); assertThat(customerMediaIdsAfterSecond) .contains(mediaIdA, mediaIdC) .doesNotContain(mediaIdB, mediaIdD); List clerkVisibleMediaIdsAfterSecond = assetsAfterSecondApprove.stream() .filter(asset -> !ClerkMediaReviewState.REJECTED.getCode().equals(asset.getReviewState())) .map(PlayClerkMediaAssetEntity::getMediaId) .collect(Collectors.toList()); assertThat(clerkVisibleMediaIdsAfterSecond) .contains(mediaIdA, mediaIdC) .doesNotContain(mediaIdB, mediaIdD); } private MockMultipartFile buildTinyPng(String filename) throws Exception { BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(image, "png", baos); return new MockMultipartFile("file", filename, "image/png", baos.toByteArray()); } private String extractMediaIdFromUpload(MockMultipartFile file, String clerkToken) throws Exception { MvcResult result = mockMvc.perform(multipart("/wx/clerk/media/upload") .file(file) .param("usage", "profile") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT) .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) .contentType(MediaType.MULTIPART_FORM_DATA)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andReturn(); String body = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(body); return root.path("data").path("mediaId").asText(); } private void submitAlbumUpdate(List mediaIds, String clerkToken) throws Exception { ObjectNode payload = objectMapper.createObjectNode(); com.fasterxml.jackson.databind.node.ArrayNode albumArray = payload.putArray("album"); mediaIds.forEach(albumArray::add); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/wx/clerk/user/updateAlbum") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT) .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) .contentType(MediaType.APPLICATION_JSON) .content(payload.toString())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } private void approveLatestAlbumReview() { List reviews = dataReviewInfoService .lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .orderByDesc(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getAddTime) .list(); assertThat(reviews).isNotEmpty(); com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity latest = reviews.get(0); com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo vo = new com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo(); vo.setId(latest.getId()); vo.setReviewState(ClerkReviewState.APPROVED); vo.setReviewCon("ok"); vo.setReviewTime(LocalDateTime.now()); dataReviewInfoService.updateDataReviewState(vo); } private void ensureTenantContext() { SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); } }