feat(media): refine clerk album review and tests

This commit is contained in:
irving
2025-12-05 22:16:01 -05:00
parent e683ef6863
commit 21bbd0386d
7 changed files with 469 additions and 30 deletions

View File

@@ -0,0 +1,271 @@
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<String> 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<PlayClerkMediaAssetEntity> 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<String> 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<String> 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<PlayClerkMediaAssetEntity> assets = mediaAssetService
.listByState(clerkId, ClerkMediaUsage.PROFILE,
Collections.singletonList(ClerkMediaReviewState.APPROVED));
List<String> assetMediaIds = assets.stream()
.map(PlayClerkMediaAssetEntity::getMediaId)
.collect(Collectors.toList());
assertThat(assetMediaIds)
.contains(media2.getId())
.doesNotContain(media1.getId());
// 顾客端视图应立即反映删除结果
PlayClerkUserInfoResultVo afterDetail = clerkUserInfoService.buildCustomerDetail(clerkId, "");
List<String> 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();
}
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);
}
}

View File

@@ -213,9 +213,23 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
assertThat(customerMediaIdsAfterFirst).containsAll(allMediaIds);
List<String> 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();
approveLatestAlbumReview();
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<PlayClerkMediaAssetEntity> assetsAfterSecondApprove = mediaAssetService
.listActiveByUsage(ApiTestDataSeeder.DEFAULT_CLERK_ID, ClerkMediaUsage.PROFILE);
@@ -224,27 +238,14 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
.filter(a -> mediaIdA.equals(a.getMediaId()))
.max(Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime))
.orElse(null);
PlayClerkMediaAssetEntity assetB = assetsAfterSecondApprove.stream()
.filter(a -> mediaIdB.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);
PlayClerkMediaAssetEntity assetD = assetsAfterSecondApprove.stream()
.filter(a -> mediaIdD.equals(a.getMediaId()))
.max(Comparator.comparing(PlayClerkMediaAssetEntity::getCreatedTime))
.orElse(null);
assertThat(assetA).isNotNull();
assertThat(assetA.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode());
assertThat(assetC).isNotNull();
assertThat(assetA.getOrderIndex()).isEqualTo(0);
assertThat(assetC.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode());
assertThat(assetB).isNotNull();
assertThat(assetB.getReviewState()).isEqualTo(ClerkMediaReviewState.REJECTED.getCode());
assertThat(assetD).isNotNull();
assertThat(assetD.getReviewState()).isEqualTo(ClerkMediaReviewState.REJECTED.getCode());
assertThat(assetC.getOrderIndex()).isEqualTo(1);
PlayClerkUserInfoResultVo customerDetailAfterSecond =
clerkUserInfoService.buildCustomerDetail(ApiTestDataSeeder.DEFAULT_CLERK_ID, "");

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl;
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.ArgumentMatchers.eq;
@@ -8,11 +9,15 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkDataType;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
@@ -49,7 +54,10 @@ class PlayClerkDataReviewInfoServiceImplTest {
ReflectionTestUtils.setField(service, "playClerkUserInfoService", clerkUserInfoService);
ReflectionTestUtils.setField(service, "clerkMediaAssetService", clerkMediaAssetService);
ReflectionTestUtils.setField(service, "mediaService", mediaService);
doReturn(true).when(service).update(any(PlayClerkDataReviewInfoEntity.class));
org.mockito.Mockito.lenient()
.doReturn(true)
.when(service)
.update(any(PlayClerkDataReviewInfoEntity.class));
}
@Test
@@ -105,6 +113,71 @@ class PlayClerkDataReviewInfoServiceImplTest {
.hasMessageContaining("店员信息不存在");
}
@Test
void enrichDataContentWithMediaPreviewPopulatesCoverAndVideoUrls() {
PlayClerkDataReviewInfoServiceImpl impl = service;
PlayClerkDataReviewReturnVo videoRow = new PlayClerkDataReviewReturnVo();
videoRow.setId("row-video");
videoRow.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
videoRow.setDataContent(java.util.Collections.singletonList("media-video"));
PlayClerkDataReviewReturnVo imageRow = new PlayClerkDataReviewReturnVo();
imageRow.setId("row-image");
imageRow.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
imageRow.setDataContent(java.util.Collections.singletonList("media-image"));
IPage<PlayClerkDataReviewReturnVo> page = new Page<>(1, 10);
page.setRecords(java.util.Arrays.asList(videoRow, imageRow));
PlayMediaEntity videoMedia = new PlayMediaEntity();
videoMedia.setId("media-video");
videoMedia.setKind(com.starry.admin.modules.media.enums.MediaKind.VIDEO.getCode());
videoMedia.setUrl("https://oss/video.mp4");
videoMedia.setCoverUrl("https://oss/video-cover.jpg");
PlayMediaEntity imageMedia = new PlayMediaEntity();
imageMedia.setId("media-image");
imageMedia.setKind(com.starry.admin.modules.media.enums.MediaKind.IMAGE.getCode());
imageMedia.setUrl("https://oss/image.png");
when(mediaService.getById("media-video")).thenReturn(videoMedia);
when(mediaService.getById("media-image")).thenReturn(imageMedia);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(
impl, "enrichDataContentWithMediaPreview", page);
assertThat(videoRow.getDataContent()).containsExactly("https://oss/video-cover.jpg");
assertThat(videoRow.getMediaVideoUrls()).containsExactly("https://oss/video.mp4");
assertThat(imageRow.getDataContent()).containsExactly("https://oss/image.png");
assertThat(imageRow.getMediaVideoUrls()).containsExactly((String) null);
}
@Test
void enrichDataContentWithMediaPreviewFallsBackToLegacyUrlWhenMediaMissing() {
PlayClerkDataReviewInfoServiceImpl impl = service;
String legacyUrl = "https://oss/legacy-only.png";
PlayClerkDataReviewReturnVo row = new PlayClerkDataReviewReturnVo();
row.setId("row-legacy");
row.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
row.setDataContent(java.util.Collections.singletonList(legacyUrl));
IPage<PlayClerkDataReviewReturnVo> page = new Page<>(1, 10);
page.setRecords(java.util.Collections.singletonList(row));
// 当无法通过 mediaId 查询到记录时,应当保持 dataContent 为原始 URL
// 并且对应的 mediaVideoUrls 位置为 null。
when(mediaService.getById(legacyUrl)).thenReturn(null);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(
impl, "enrichDataContentWithMediaPreview", page);
assertThat(row.getDataContent()).containsExactly(legacyUrl);
assertThat(row.getMediaVideoUrls()).containsExactly((String) null);
}
private PlayClerkDataReviewInfoEntity buildReview(String id, String clerkId, String dataType,
java.util.List<String> payload) {
PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();

View File

@@ -24,4 +24,23 @@ class PlayClerkUserInfoServiceImplTest {
assertThat(merged.stream().map(MediaVo::getUrl))
.containsExactlyInAnyOrder("https://oss/2.png", "https://oss/1.png");
}
@Test
void mergeLegacyAlbumFillsUsageStatusAndReviewStateForLegacyEntries() {
List<String> legacy = Arrays.asList("https://oss/legacy-only.png");
List<MediaVo> destination = new ArrayList<>();
List<MediaVo> merged = PlayClerkUserInfoServiceImpl.mergeLegacyAlbum(legacy, destination);
assertThat(merged).hasSize(1);
MediaVo legacyVo = merged.get(0);
assertThat(legacyVo.getUrl()).isEqualTo("https://oss/legacy-only.png");
assertThat(legacyVo.getId())
.as("legacy media id should fallback to url for compatibility")
.isEqualTo("https://oss/legacy-only.png");
assertThat(legacyVo.getUsage()).isEqualTo(com.starry.admin.modules.clerk.enums.ClerkMediaUsage.PROFILE.getCode());
assertThat(legacyVo.getStatus()).isEqualTo(com.starry.admin.modules.media.enums.MediaStatus.READY.getCode());
assertThat(legacyVo.getReviewState())
.isEqualTo(com.starry.admin.modules.clerk.enums.ClerkMediaReviewState.APPROVED.getCode());
}
}

View File

@@ -56,7 +56,6 @@ class MediaUploadServiceTest {
MultipartFile file = buildImageFile("avatar.png", "image/png");
when(mediaService.normalizeAndSave(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(mediaService.updateById(any())).thenReturn(true);
when(ossFileService.upload(any(), eq(clerk.getTenantId()), any()))
.thenReturn("https://oss.mock/avatar.png");