package com.starry.admin.api; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.starry.admin.common.apitest.ApiTestDataSeeder; 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.enums.MediaStatus; import com.starry.admin.modules.media.service.IPlayMediaService; 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 java.util.Collections; import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; /** * 专门验证 /wx/clerk/user/updateAlbum 在“只有删除/排序”时不会创建新的审核记录, * 并且会立即更新顾客端视图。 */ class WxClerkAlbumUpdateApiTest extends AbstractApiTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private WxTokenService wxTokenService; @Autowired private IPlayClerkUserInfoService clerkUserInfoService; @Autowired private IPlayClerkDataReviewInfoService dataReviewInfoService; @Autowired private IPlayClerkMediaAssetService mediaAssetService; @Autowired private IPlayMediaService mediaService; @Test void reorderExistingApprovedMediaDoesNotCreateNewReviewAndUpdatesOrder() throws Exception { ensureTenantContext(); String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID; String clerkToken = wxTokenService.createWxUserToken(clerkId); clerkUserInfoService.updateTokenById(clerkId, clerkToken); PlayMediaEntity media1 = seedMedia(clerkId); PlayMediaEntity media2 = seedMedia(clerkId); PlayMediaEntity media3 = seedMedia(clerkId); seedApprovedAsset(clerkId, media1.getId(), 0); seedApprovedAsset(clerkId, media2.getId(), 1); seedApprovedAsset(clerkId, media3.getId(), 2); long reviewCountBefore = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); // 仅调整顺序:album 只包含已审核通过的媒资 id,不引入任何新媒资 List reordered = List.of(media3.getId(), media1.getId(), media2.getId()); ObjectNode payload = objectMapper.createObjectNode(); com.fasterxml.jackson.databind.node.ArrayNode albumArray = payload.putArray("album"); reordered.forEach(albumArray::add); mockMvc.perform(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)); ensureTenantContext(); long reviewCountAfter = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); assertThat(reviewCountAfter) .as("reordering without introducing new media should not create review records") .isEqualTo(reviewCountBefore); List assets = mediaAssetService .listByState(clerkId, ClerkMediaUsage.PROFILE, Collections.singletonList(ClerkMediaReviewState.APPROVED)); PlayClerkMediaAssetEntity asset1 = assets.stream() .filter(a -> media1.getId().equals(a.getMediaId())) .max(java.util.Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime)) .orElse(null); PlayClerkMediaAssetEntity asset2 = assets.stream() .filter(a -> media2.getId().equals(a.getMediaId())) .max(java.util.Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime)) .orElse(null); PlayClerkMediaAssetEntity asset3 = assets.stream() .filter(a -> media3.getId().equals(a.getMediaId())) .max(java.util.Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime)) .orElse(null); assertThat(asset3).as("asset for media3 should exist").isNotNull(); assertThat(asset1).as("asset for media1 should exist").isNotNull(); assertThat(asset2).as("asset for media2 should exist").isNotNull(); assertThat(asset3.getOrderIndex()).isEqualTo(0); assertThat(asset1.getOrderIndex()).isEqualTo(1); assertThat(asset2.getOrderIndex()).isEqualTo(2); PlayClerkUserInfoResultVo detail = clerkUserInfoService.buildCustomerDetail(clerkId, ""); List customerMediaIds = detail.getMediaList().stream() .map(MediaVo::getMediaId) .collect(Collectors.toList()); int index3 = customerMediaIds.indexOf(media3.getId()); int index1 = customerMediaIds.indexOf(media1.getId()); int index2 = customerMediaIds.indexOf(media2.getId()); assertThat(index3).isGreaterThanOrEqualTo(0); assertThat(index1).isGreaterThanOrEqualTo(0); assertThat(index2).isGreaterThanOrEqualTo(0); assertThat(index3).isLessThan(index1); assertThat(index1).isLessThan(index2); } @Test void deleteAndReorderAlbumDoesNotCreateNewReviewAndIsImmediatelyVisible() throws Exception { ensureTenantContext(); String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID; String clerkToken = wxTokenService.createWxUserToken(clerkId); clerkUserInfoService.updateTokenById(clerkId, clerkToken); // 预置两条已审核通过的 PROFILE 媒资 PlayMediaEntity media1 = seedMedia(clerkId); PlayMediaEntity media2 = seedMedia(clerkId); seedApprovedAsset(clerkId, media1.getId(), 0); seedApprovedAsset(clerkId, media2.getId(), 1); // 顾客端初始视图应包含两条媒资 PlayClerkUserInfoResultVo beforeDetail = clerkUserInfoService.buildCustomerDetail(clerkId, ""); List beforeIds = beforeDetail.getMediaList().stream() .map(MediaVo::getMediaId) .collect(Collectors.toList()); assertThat(beforeIds).contains(media1.getId(), media2.getId()); // 记录当前相册审核记录数量 long reviewCountBefore = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); // 现在通过 updateAlbum 只保留 media2,相当于“删除 media1 + 不引入新媒资” ObjectNode payload = objectMapper.createObjectNode(); payload.putArray("album").add(media2.getId()); mockMvc.perform(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)); ensureTenantContext(); long reviewCountAfter = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); assertThat(reviewCountAfter) .as("Deleting/reordering without new media should not create new review records") .isEqualTo(reviewCountBefore); // 资产表中仅剩 media2 且仍为 APPROVED 状态 List assets = mediaAssetService .listByState(clerkId, ClerkMediaUsage.PROFILE, Collections.singletonList(ClerkMediaReviewState.APPROVED)); List assetMediaIds = assets.stream() .map(PlayClerkMediaAssetEntity::getMediaId) .collect(Collectors.toList()); assertThat(assetMediaIds) .contains(media2.getId()) .doesNotContain(media1.getId()); // 顾客端视图应立即反映删除结果 PlayClerkUserInfoResultVo afterDetail = clerkUserInfoService.buildCustomerDetail(clerkId, ""); List afterIds = afterDetail.getMediaList().stream() .map(MediaVo::getMediaId) .collect(Collectors.toList()); assertThat(afterIds) .contains(media2.getId()) .doesNotContain(media1.getId()); } @Test void updateAlbumRejectsEmptyAlbumPayload() throws Exception { ensureTenantContext(); String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID; String clerkToken = wxTokenService.createWxUserToken(clerkId); clerkUserInfoService.updateTokenById(clerkId, clerkToken); ObjectNode payload = objectMapper.createObjectNode(); payload.putArray("album"); // 空数组 MvcResult result = mockMvc.perform(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()) .andReturn(); String body = result.getResponse().getContentAsString(); com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body); assertThat(root.path("code").asInt()) .as("empty album should be rejected, response=%s", body) .isEqualTo(500); assertThat(root.path("message").asText()) .as("error message for empty album should be present, response=%s", body) .isNotBlank(); } @Test void updateAlbumAllowsMixedLegacyUrlsAndNewMediaIdsForReview() throws Exception { ensureTenantContext(); String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID; String clerkToken = wxTokenService.createWxUserToken(clerkId); clerkUserInfoService.updateTokenById(clerkId, clerkToken); // 预置一条已就绪的媒资,模拟“新上传的视频/图片” PlayMediaEntity media = seedMedia(clerkId); // 模拟老相册中的 URL(未媒资化的历史数据) String legacyUrl1 = "https://oss.apitest/legacy-1.png"; String legacyUrl2 = "https://oss.apitest/legacy-2.png"; long reviewCountBefore = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); ObjectNode payload = objectMapper.createObjectNode(); com.fasterxml.jackson.databind.node.ArrayNode albumArray = payload.putArray("album"); albumArray.add(legacyUrl1); albumArray.add(legacyUrl2); albumArray.add(media.getId()); MvcResult result = mockMvc.perform(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()) .andReturn(); String body = result.getResponse().getContentAsString(); com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body); assertThat(root.path("code").asInt()) .as("mixed legacy URLs and new media ids should be accepted for review, response=%s", body) .isEqualTo(200); ensureTenantContext(); long reviewCountAfter = dataReviewInfoService.lambdaQuery() .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId) .eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2") .count(); assertThat(reviewCountAfter) .as("mixed legacy URLs and new media ids should create exactly one new review record") .isEqualTo(reviewCountBefore + 1); } private PlayMediaEntity seedMedia(String clerkId) { String mediaId = "media-" + java.util.UUID.randomUUID().toString().substring(0, 16); PlayMediaEntity entity = new PlayMediaEntity(); entity.setId(mediaId); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setOwnerType(MediaOwnerType.CLERK); entity.setOwnerId(clerkId); entity.setKind("image"); entity.setStatus(MediaStatus.READY.getCode()); entity.setUrl("https://oss.apitest/" + mediaId + ".png"); mediaService.save(entity); return entity; } private void seedApprovedAsset(String clerkId, String mediaId, int orderIndex) { PlayClerkMediaAssetEntity asset = new PlayClerkMediaAssetEntity(); asset.setId("asset-" + mediaId); asset.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); asset.setClerkId(clerkId); asset.setMediaId(mediaId); asset.setUsage(ClerkMediaUsage.PROFILE.getCode()); asset.setOrderIndex(orderIndex); asset.setReviewState(ClerkMediaReviewState.APPROVED.getCode()); asset.setDeleted(false); mediaAssetService.save(asset); } private void ensureTenantContext() { SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); } }