From 086aa4722658f4dad80bb8bbd8eb7d1b7c1fe19e Mon Sep 17 00:00:00 2001 From: irving Date: Thu, 4 Dec 2025 22:27:03 -0500 Subject: [PATCH] feat(media): clerk profile media flow --- docker/docker-compose-apitest.yml | 2 +- .../vo/PlayClerkDataReviewReturnVo.java | 9 + .../service/IPlayClerkUserInfoService.java | 11 + .../PlayClerkDataReviewInfoServiceImpl.java | 102 +++++- .../impl/PlayClerkUserInfoServiceImpl.java | 27 ++ .../clerk/task/ClerkAlbumMigrationRunner.java | 15 +- .../service/impl/PlayMediaServiceImpl.java | 2 +- .../controller/WxCustomController.java | 11 +- .../weichat/service/MediaUploadService.java | 9 +- .../api/WxClerkMediaControllerApiTest.java | 328 ++++++++++++++++++ 10 files changed, 483 insertions(+), 33 deletions(-) create mode 100644 play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java diff --git a/docker/docker-compose-apitest.yml b/docker/docker-compose-apitest.yml index 1292586..dd4abae 100644 --- a/docker/docker-compose-apitest.yml +++ b/docker/docker-compose-apitest.yml @@ -1,7 +1,7 @@ version: "3.9" services: mysql-apitest: - image: mysql:8.0.24 + image: mysql:8.0 container_name: peipei-mysql-apitest restart: unless-stopped environment: diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/vo/PlayClerkDataReviewReturnVo.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/vo/PlayClerkDataReviewReturnVo.java index 9f6d3b5..2f0583b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/vo/PlayClerkDataReviewReturnVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/vo/PlayClerkDataReviewReturnVo.java @@ -60,6 +60,15 @@ public class PlayClerkDataReviewReturnVo { @ApiModelProperty(value = "资料内容", example = "[\"https://example.com/photo1.jpg\"]", notes = "资料内容,根据资料类型有不同格式") private List dataContent; + /** + * 媒资对应的视频地址(仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应) + */ + @ApiModelProperty( + value = "媒资视频地址列表", + example = "[\"https://example.com/video1.mp4\"]", + notes = "仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应") + private List mediaVideoUrls; + /** * 审核状态(0:未审核:1:审核通过,2:审核不通过) */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkUserInfoService.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkUserInfoService.java index dd14d4a..732575b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkUserInfoService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkUserInfoService.java @@ -190,6 +190,17 @@ public interface IPlayClerkUserInfoService extends IService selectPlayClerkUserInfoByPage(PlayClerkUserInfoQueryVo vo); + /** + * 构建面向顾客的店员详情视图对象(包含媒资与兼容相册)。 + * + * @param clerkId + * 店员ID + * @param customUserId + * 顾客ID(可为空,用于标记关注状态) + * @return 店员详情视图对象 + */ + PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId); + /** * 确认店员处于可用状态,否则抛出异常 * diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImpl.java index fba3ed9..b3392b6 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImpl.java @@ -13,6 +13,7 @@ import com.starry.admin.modules.clerk.mapper.PlayClerkDataReviewInfoMapper; import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity; +import com.starry.admin.modules.clerk.module.enums.ClerkDataType; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewQueryVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo; @@ -126,8 +127,11 @@ public class PlayClerkDataReviewInfoServiceImpl lambdaQueryWrapper.between(PlayClerkDataReviewInfoEntity::getAddTime, vo.getAddTime().get(0), vo.getAddTime().get(1)); } - return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), - PlayClerkDataReviewReturnVo.class, lambdaQueryWrapper); + IPage page = this.baseMapper.selectJoinPage( + new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkDataReviewReturnVo.class, + lambdaQueryWrapper); + enrichDataContentWithMediaPreview(page); + return page; } /** @@ -148,6 +152,72 @@ public class PlayClerkDataReviewInfoServiceImpl return save(playClerkDataReviewInfo); } + /** + * 为头像 / 相册审核记录补充可预览的 URL。 + * + *

dataContent 中现在可能是媒资 ID(mediaId)或历史 URL,这里做一次向前兼容: + *

    + *
  • 如果是 mediaId,则解析到 play_media 记录,并返回封面或原始 URL;
  • + *
  • 如果查不到媒资,则保留原值。
  • + *
+ * 这样 PC 端审核页面始终可以正确预览图片/视频。

+ */ + private void enrichDataContentWithMediaPreview(IPage page) { + if (page == null || page.getRecords() == null || page.getRecords().isEmpty()) { + return; + } + for (PlayClerkDataReviewReturnVo row : page.getRecords()) { + ClerkDataType type = row.getDataTypeEnum(); + if (type == null) { + continue; + } + if (type == ClerkDataType.AVATAR || type == ClerkDataType.PHOTO_ALBUM) { + List content = row.getDataContent(); + if (CollectionUtil.isEmpty(content)) { + continue; + } + List previewUrls = new ArrayList<>(); + List videoUrls = new ArrayList<>(); + for (String value : content) { + if (StrUtil.isBlank(value)) { + continue; + } + MediaPreviewPair pair = resolvePreviewPair(value); + if (pair == null || StrUtil.isBlank(pair.getPreviewUrl())) { + continue; + } + previewUrls.add(pair.getPreviewUrl()); + videoUrls.add(pair.getVideoUrl()); + } + row.setDataContent(previewUrls); + row.setMediaVideoUrls(videoUrls); + } + } + } + + private MediaPreviewPair resolvePreviewPair(String value) { + if (StrUtil.isBlank(value)) { + return null; + } + PlayMediaEntity media = mediaService.getById(value); + if (media == null) { + MediaPreviewPair fallback = new MediaPreviewPair(); + fallback.setPreviewUrl(value); + fallback.setVideoUrl(null); + return fallback; + } + MediaPreviewPair pair = new MediaPreviewPair(); + if (MediaKind.VIDEO.getCode().equals(media.getKind())) { + String coverUrl = StrUtil.isNotBlank(media.getCoverUrl()) ? media.getCoverUrl() : media.getUrl(); + pair.setPreviewUrl(coverUrl); + pair.setVideoUrl(media.getUrl()); + } else { + pair.setPreviewUrl(media.getUrl()); + pair.setVideoUrl(null); + } + return pair; + } + @Override public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) { PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId()); @@ -233,13 +303,13 @@ public class PlayClerkDataReviewInfoServiceImpl media.setOwnerType(MediaOwnerType.CLERK); media.setOwnerId(clerkInfo.getId()); media.setKind(MediaKind.IMAGE.getCode()); - media.setStatus(MediaStatus.APPROVED.getCode()); + media.setStatus(MediaStatus.READY.getCode()); media.setUrl(url); Map metadata = new HashMap<>(); metadata.put("legacySource", "album_review"); media.setMetadata(metadata); mediaService.normalizeAndSave(media); - media.setStatus(MediaStatus.APPROVED.getCode()); + media.setStatus(MediaStatus.READY.getCode()); mediaService.updateById(media); return media; } @@ -279,4 +349,28 @@ public class PlayClerkDataReviewInfoServiceImpl public int deletePlayClerkDataReviewInfoById(String id) { return playClerkDataReviewInfoMapper.deleteById(id); } + + /** + * 简单的预览地址 / 视频地址对,避免在主体逻辑中使用 Map 或魔法下标。 + */ + private static class MediaPreviewPair { + private String previewUrl; + private String videoUrl; + + String getPreviewUrl() { + return previewUrl; + } + + void setPreviewUrl(String previewUrl) { + this.previewUrl = previewUrl; + } + + String getVideoUrl() { + return videoUrl; + } + + void setVideoUrl(String videoUrl) { + this.videoUrl = videoUrl; + } + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImpl.java index d005604..e414fd3 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImpl.java @@ -548,6 +548,33 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId) + .eq(PlayCustomFollowInfoEntity::getClerkId, clerkId); + PlayCustomFollowInfoEntity followInfo = customFollowInfoService.getOne(wrapper, false); + if (followInfo != null && "1".equals(followInfo.getFollowState())) { + followState = "1"; + } + } + vo.setFollowState(followState); + + List mediaList = loadMediaForClerk(clerkId, false); + vo.setMediaList(mergeLegacyAlbum(entity.getAlbum(), mediaList)); + return vo; + } + /** * 新增店员 * diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java index bc04d81..d786e1b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java @@ -15,10 +15,8 @@ import com.starry.admin.utils.SecurityUtils; import com.starry.common.utils.IdUtils; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -79,10 +77,8 @@ public class ClerkAlbumMigrationRunner implements ApplicationRunner { if (media == null) { continue; } - if (!MediaStatus.APPROVED.getCode().equals(media.getStatus())) { - media.setStatus(MediaStatus.APPROVED.getCode()); - mediaService.updateById(media); - } + media.setStatus(MediaStatus.READY.getCode()); + mediaService.updateById(media); clerkMediaAssetService.linkDraftAsset(clerk.getTenantId(), clerk.getId(), media.getId(), ClerkMediaUsage.PROFILE); approvedMediaIds.add(media.getId()); @@ -90,11 +86,6 @@ public class ClerkAlbumMigrationRunner implements ApplicationRunner { clerkMediaAssetService.applyReviewDecision(clerk.getId(), ClerkMediaUsage.PROFILE, approvedMediaIds, null, null); - - PlayClerkUserInfoEntity update = new PlayClerkUserInfoEntity(); - update.setId(clerk.getId()); - update.setAlbum(new ArrayList<>()); - clerkUserInfoService.update(update); migratedOwners.incrementAndGet(); migratedMedia.addAndGet(approvedMediaIds.size()); log.info("[ClerkAlbumMigration] processed {} media for clerk {}", approvedMediaIds.size(), @@ -130,7 +121,7 @@ public class ClerkAlbumMigrationRunner implements ApplicationRunner { media.setOwnerType(MediaOwnerType.CLERK); media.setOwnerId(clerk.getId()); media.setKind(MediaKind.IMAGE.getCode()); - media.setStatus(MediaStatus.APPROVED.getCode()); + media.setStatus(MediaStatus.READY.getCode()); media.setUrl(value); Map metadata = new HashMap<>(); metadata.put("legacySource", "album_migration"); diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java index 7e3714a..1eba014 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java @@ -40,7 +40,7 @@ public class PlayMediaServiceImpl extends ServiceImpl listApprovedByOwner(String ownerType, String ownerId) { - return listByOwner(ownerType, ownerId, Collections.singleton(MediaStatus.APPROVED.getCode())); + return listByOwner(ownerType, ownerId, Collections.singleton(MediaStatus.READY.getCode())); } @Override diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java index 93b5e37..1e6552e 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java @@ -151,16 +151,9 @@ public class WxCustomController { @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PlayClerkUserInfoResultVo.class)}) @GetMapping("/queryClerkDetailedById") public R queryClerkDetailedById(@RequestParam("id") String id) { - PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(id); - PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class); - vo.setAddress(entity.getCity()); - // 查询是否关注,未登录情况下,默认为未关注 String loginUserId = customUserService.getLoginUserId(); - if (StringUtils.isNotEmpty(loginUserId)) { - vo.setFollowState(playCustomFollowInfoService.queryFollowState(loginUserId, vo.getId())); - } - // 服务项目 - vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1")); + PlayClerkUserInfoResultVo vo = clerkUserInfoService.buildCustomerDetail(id, + StringUtils.isNotEmpty(loginUserId) ? loginUserId : ""); return R.ok(vo); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java index 95c0e5d..5abf034 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java @@ -47,7 +47,7 @@ import ws.schild.jave.info.VideoSize; public class MediaUploadService { private static final long MAX_VIDEO_BYTES = 30L * 1024 * 1024; - private static final long MAX_VIDEO_DURATION_MS = 30_000; + private static final long MAX_VIDEO_DURATION_MS = 45_000; private static final String IMAGE_OUTPUT_FORMAT = "image2"; private static final String VIDEO_OUTPUT_FORMAT = "mp4"; @@ -85,9 +85,6 @@ public class MediaUploadService { isVideo ? MediaKind.VIDEO : MediaKind.IMAGE); entity.getMetadata().put("detectedType", detectedType); entity.getMetadata().put("isVideo", isVideo); - mediaService.normalizeAndSave(entity); - entity.setStatus(MediaStatus.PROCESSING.getCode()); - mediaService.updateById(entity); if (isImage) { handleImageUpload(tempFile, entity, clerkInfo, originalFilename); @@ -97,7 +94,7 @@ public class MediaUploadService { handleVideoUpload(tempFile, processedVideoFile, coverFile, entity, clerkInfo, originalFilename); } entity.setStatus(MediaStatus.READY.getCode()); - mediaService.updateById(entity); + mediaService.normalizeAndSave(entity); PlayClerkMediaAssetEntity asset = clerkMediaAssetService.linkDraftAsset( clerkInfo.getTenantId(), clerkInfo.getId(), @@ -161,7 +158,7 @@ public class MediaUploadService { } long durationMs = info.getDuration(); if (durationMs > MAX_VIDEO_DURATION_MS) { - throw new CustomException("视频时长不能超过30秒"); + throw new CustomException("视频时长不能超过45秒"); } VideoInfo videoInfo = info.getVideo(); VideoSize size = videoInfo.getSize(); diff --git a/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java new file mode 100644 index 0000000..bd2d79a --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerApiTest.java @@ -0,0 +1,328 @@ +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); + submitAlbumUpdate(keptMedia, clerkToken); + ensureTenantContext(); + approveLatestAlbumReview(); + + 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 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(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()); + + 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); + } +}