From 8558d203afb5ede966aec02fecb62b279a293241 Mon Sep 17 00:00:00 2001 From: irving Date: Sun, 16 Nov 2025 11:33:58 -0500 Subject: [PATCH] wip: media migration progress --- media-migration-to-test.md | 24 ++ .../clerk/enums/ClerkMediaReviewState.java | 30 ++ .../modules/clerk/enums/ClerkMediaUsage.java | 32 ++ .../mapper/PlayClerkMediaAssetMapper.java | 8 + .../clerk/mapper/PlayClerkUserInfoMapper.java | 6 + .../entity/PlayClerkMediaAssetEntity.java | 40 +++ .../module/entity/PlayClerkUserReturnVo.java | 7 + .../service/IPlayClerkMediaAssetService.java | 25 ++ .../service/IPlayClerkUserInfoService.java | 7 + .../PlayClerkDataReviewInfoServiceImpl.java | 87 +++++- .../impl/PlayClerkMediaAssetServiceImpl.java | 280 +++++++++++++++++ .../impl/PlayClerkUserInfoServiceImpl.java | 132 +++++++- .../clerk/task/ClerkAlbumMigrationRunner.java | 141 +++++++++ .../modules/media/entity/PlayMediaEntity.java | 92 ++++++ .../admin/modules/media/enums/MediaKind.java | 33 ++ .../modules/media/enums/MediaOwnerType.java | 9 + .../modules/media/enums/MediaStatus.java | 22 ++ .../modules/media/mapper/PlayMediaMapper.java | 8 + .../media/service/IPlayMediaService.java | 21 ++ .../service/impl/PlayMediaServiceImpl.java | 136 +++++++++ .../assembler/ClerkMediaAssembler.java | 52 ++++ .../weichat/assembler/MediaAssembler.java | 44 +++ .../weichat/controller/WxClerkController.java | 5 +- .../controller/WxClerkMediaController.java | 121 ++++++++ .../entity/PlayClerkUserLoginResponseVo.java | 6 + .../entity/clerk/MediaOrderRequest.java | 14 + .../modules/weichat/entity/clerk/MediaVo.java | 44 +++ .../clerk/PlayClerkUserInfoResultVo.java | 6 + .../weichat/service/MediaUploadService.java | 287 ++++++++++++++++++ .../src/main/resources/application-dev.yml | 5 +- .../db/migration/V17__create_media.sql | 43 +++ .../admin/api/PlayClerkUserInfoApiTest.java | 4 +- ...layClerkDataReviewInfoServiceImplTest.java | 118 +++++++ .../PlayClerkMediaAssetServiceImplTest.java | 128 ++++++++ .../PlayClerkUserInfoServiceImplTest.java | 27 ++ .../WxClerkMediaControllerTest.java | 143 +++++++++ .../service/MediaUploadServiceTest.java | 138 +++++++++ 37 files changed, 2314 insertions(+), 11 deletions(-) create mode 100644 media-migration-to-test.md create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaReviewState.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaUsage.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkMediaAssetMapper.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkMediaAssetEntity.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkMediaAssetService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImpl.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/entity/PlayMediaEntity.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaKind.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaOwnerType.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaStatus.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/mapper/PlayMediaMapper.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/service/IPlayMediaService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/ClerkMediaAssembler.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/MediaAssembler.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaOrderRequest.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaVo.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java create mode 100644 play-admin/src/main/resources/db/migration/V17__create_media.sql create mode 100644 play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImplTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImplTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImplTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/weichat/controller/WxClerkMediaControllerTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/weichat/service/MediaUploadServiceTest.java diff --git a/media-migration-to-test.md b/media-migration-to-test.md new file mode 100644 index 0000000..3239261 --- /dev/null +++ b/media-migration-to-test.md @@ -0,0 +1,24 @@ +## 媒資/相簿相容性:手動驗證清單 + +1. **WeChat 店員端 - 個資頁預覽** + - 登入店員帳號進入「我的資料」,確認相簿縮圖顯示為合併後的 `mediaList + album`,不應出現重覆 URL。 + - 點擊「照片」進入管理介面,確認 legacy album 中的圖片仍存在;上傳新媒資後應立即出現於列表。 + +2. **媒資上傳與排序** + - 上傳圖片與影片各一,測試格式/大小超限的錯誤提示與成功上傳後的狀態。 + - 排序、刪除媒資並提交審核,確認前端列表與預覽更新,且不會重複顯示相同 URL。 + +3. **後台店員列表** + - 在管理端店員列表中,確認每位店員的照片區塊都展示合併後的媒資清單(舊 album + 新媒資)。 + - 點擊圖片預覽,確認輪播順序正確、無重覆 URL。 + +4. **後台店員審核詳情** + - 查看一筆含多張舊相簿照片的申請,確認圖片區塊已改用 `mediaGallery`,兼容新舊媒資。 + - 點擊照片預覽,確認圖片來源為合併後的清單。 + +5. **API 回應驗證** + - 呼叫 `/clerk/user/list` 或 WeChat 前端使用的 API,檢查 `album` 欄位仍保留原值,`mediaList` 會包含新媒資並附帶 legacy URL(無重複)。 + - 若資料仍未遷移,確保 `mediaList` 仍會帶上舊 `album` 的 URL。 + +6. **媒資審核流程** + - 走一次「上傳 → 排序 → 提交 → 審核」流程,確認審核通過後 `mediaList` 只保留 Approved 的媒資,但 `album` 不會被清除,舊客端仍能看到舊資料。 diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaReviewState.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaReviewState.java new file mode 100644 index 0000000..67aa64d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaReviewState.java @@ -0,0 +1,30 @@ +package com.starry.admin.modules.clerk.enums; + +import lombok.Getter; + +@Getter +public enum ClerkMediaReviewState { + + DRAFT("draft"), + PENDING("pending"), + APPROVED("approved"), + REJECTED("rejected"); + + private final String code; + + ClerkMediaReviewState(String code) { + this.code = code; + } + + public static ClerkMediaReviewState fromCode(String code) { + if (code == null || code.isEmpty()) { + return DRAFT; + } + for (ClerkMediaReviewState state : values()) { + if (state.code.equalsIgnoreCase(code) || state.name().equalsIgnoreCase(code)) { + return state; + } + } + return DRAFT; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaUsage.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaUsage.java new file mode 100644 index 0000000..2069a01 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/enums/ClerkMediaUsage.java @@ -0,0 +1,32 @@ +package com.starry.admin.modules.clerk.enums; + +import lombok.Getter; + +@Getter +public enum ClerkMediaUsage { + + PROFILE("profile"), + AVATAR("avatar"), + MOMENTS("moments"), + VOICE_INTRO("voice_intro"), + PROMO("promo"), + OTHER("other"); + + private final String code; + + ClerkMediaUsage(String code) { + this.code = code; + } + + public static ClerkMediaUsage fromCode(String code) { + if (code == null || code.isEmpty()) { + return PROFILE; + } + for (ClerkMediaUsage usage : values()) { + if (usage.code.equalsIgnoreCase(code) || usage.name().equalsIgnoreCase(code)) { + return usage; + } + } + return PROFILE; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkMediaAssetMapper.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkMediaAssetMapper.java new file mode 100644 index 0000000..2db51a6 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkMediaAssetMapper.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.clerk.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity; + +public interface PlayClerkMediaAssetMapper extends BaseMapper { + +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkUserInfoMapper.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkUserInfoMapper.java index 82e7d74..ec891f8 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkUserInfoMapper.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkUserInfoMapper.java @@ -1,7 +1,10 @@ package com.starry.admin.modules.clerk.mapper; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.github.yulichang.base.MPJBaseMapper; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import java.util.List; +import org.apache.ibatis.annotations.Select; /** * 店员Mapper接口 @@ -11,4 +14,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; */ public interface PlayClerkUserInfoMapper extends MPJBaseMapper { + @InterceptorIgnore(tenantLine = "true") + @Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL") + List selectAllWithAlbumIgnoringTenant(); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkMediaAssetEntity.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkMediaAssetEntity.java new file mode 100644 index 0000000..5b15c9a --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkMediaAssetEntity.java @@ -0,0 +1,40 @@ +package com.starry.admin.modules.clerk.module.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.starry.common.domain.BaseEntity; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName(value = "play_clerk_media_asset") +public class PlayClerkMediaAssetEntity extends BaseEntity { + + @TableId + private String id; + + private String clerkId; + + /** + * 租戶 ID,供 TenantLine 過濾 + */ + private String tenantId; + + private String mediaId; + + @TableField("`usage`") + private String usage; + + private String reviewState; + + private Integer orderIndex; + + private LocalDateTime submittedTime; + + private String reviewRecordId; + + private String note; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkUserReturnVo.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkUserReturnVo.java index cf543d3..ae0b73f 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkUserReturnVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkUserReturnVo.java @@ -3,6 +3,7 @@ package com.starry.admin.modules.clerk.module.entity; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnore; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import java.math.BigDecimal; @@ -94,6 +95,12 @@ public class PlayClerkUserReturnVo { @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表") private List album = new ArrayList<>(); + /** + * 媒资列表 + */ + @ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据") + private List mediaList = new ArrayList<>(); + /** * 个性签名 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkMediaAssetService.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkMediaAssetService.java new file mode 100644 index 0000000..d989a27 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkMediaAssetService.java @@ -0,0 +1,25 @@ +package com.starry.admin.modules.clerk.service; + +import com.baomidou.mybatisplus.extension.service.IService; +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 java.util.Collection; +import java.util.List; + +public interface IPlayClerkMediaAssetService extends IService { + + PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId, ClerkMediaUsage usage); + + void submitWithOrder(String clerkId, ClerkMediaUsage usage, List mediaIds); + + void reorder(String clerkId, ClerkMediaUsage usage, List mediaIds); + + void softDelete(String clerkId, String mediaId); + + List listByState(String clerkId, ClerkMediaUsage usage, Collection states); + + List listActiveByUsage(String clerkId, ClerkMediaUsage usage); + + void applyReviewDecision(String clerkId, ClerkMediaUsage usage, List approvedValues, String reviewRecordId, String note); +} 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 6507fe7..dd14d4a 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 @@ -252,5 +252,12 @@ public interface IPlayClerkUserInfoService extends IService simpleList(); + /** + * 查询存在相册字段数据的店员(忽略租户隔离) + * + * @return 店员集合 + */ + List listWithAlbumIgnoringTenant(); + JSONObject getPcData(PlayClerkUserInfoEntity entity); } 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 f9e346c..fba3ed9 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 @@ -1,5 +1,6 @@ package com.starry.admin.modules.clerk.service.impl; +import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; @@ -7,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.clerk.enums.ClerkMediaUsage; 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; @@ -15,12 +17,23 @@ 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; 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.MediaKind; +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.common.enums.ClerkReviewState; import com.starry.common.utils.IdUtils; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import javax.annotation.Resource; import org.springframework.stereotype.Service; @@ -42,6 +55,12 @@ public class PlayClerkDataReviewInfoServiceImpl @Resource private IPlayClerkUserInfoService playClerkUserInfoService; + @Resource + private IPlayClerkMediaAssetService clerkMediaAssetService; + + @Resource + private IPlayMediaService mediaService; + /** * 查询店员资料审核 * @@ -147,7 +166,8 @@ public class PlayClerkDataReviewInfoServiceImpl userInfo.setAvatar(entity.getDataContent().get(0)); } if ("2".equals(entity.getDataType())) { - userInfo.setAlbum(entity.getDataContent()); + userInfo.setAlbum(new ArrayList<>()); + synchronizeApprovedAlbumMedia(entity); } if ("3".equals(entity.getDataType())) { userInfo.setAudio(entity.getDataContent().get(0)); @@ -159,6 +179,71 @@ public class PlayClerkDataReviewInfoServiceImpl } } + private void synchronizeApprovedAlbumMedia(PlayClerkDataReviewInfoEntity reviewInfo) { + PlayClerkUserInfoEntity clerkInfo = playClerkUserInfoService.getById(reviewInfo.getClerkId()); + if (clerkInfo == null) { + throw new CustomException("店员信息不存在,无法同步媒资"); + } + + List rawContent = reviewInfo.getDataContent(); + List sanitized = CollectionUtil.isEmpty(rawContent) + ? Collections.emptyList() + : rawContent.stream().filter(StrUtil::isNotBlank).map(String::trim).distinct() + .collect(Collectors.toList()); + + List resolvedMediaIds = new ArrayList<>(); + for (String value : sanitized) { + PlayMediaEntity media = resolveMediaEntity(clerkInfo, value); + if (media == null) { + continue; + } + clerkMediaAssetService.linkDraftAsset(clerkInfo.getTenantId(), clerkInfo.getId(), media.getId(), + ClerkMediaUsage.PROFILE); + resolvedMediaIds.add(media.getId()); + } + + clerkMediaAssetService.applyReviewDecision(clerkInfo.getId(), ClerkMediaUsage.PROFILE, resolvedMediaIds, + reviewInfo.getId(), reviewInfo.getReviewCon()); + } + + private PlayMediaEntity resolveMediaEntity(PlayClerkUserInfoEntity clerkInfo, String value) { + if (StrUtil.isBlank(value)) { + return null; + } + PlayMediaEntity media = mediaService.getById(value); + if (media != null) { + return media; + } + media = mediaService.lambdaQuery() + .eq(PlayMediaEntity::getOwnerType, MediaOwnerType.CLERK) + .eq(PlayMediaEntity::getOwnerId, clerkInfo.getId()) + .eq(PlayMediaEntity::getUrl, value) + .last("limit 1") + .one(); + if (media != null) { + return media; + } + return createMediaFromLegacyUrl(clerkInfo, value); + } + + private PlayMediaEntity createMediaFromLegacyUrl(PlayClerkUserInfoEntity clerkInfo, String url) { + PlayMediaEntity media = new PlayMediaEntity(); + media.setId(IdUtils.getUuid()); + media.setTenantId(clerkInfo.getTenantId()); + media.setOwnerType(MediaOwnerType.CLERK); + media.setOwnerId(clerkInfo.getId()); + media.setKind(MediaKind.IMAGE.getCode()); + media.setStatus(MediaStatus.APPROVED.getCode()); + media.setUrl(url); + Map metadata = new HashMap<>(); + metadata.put("legacySource", "album_review"); + media.setMetadata(metadata); + mediaService.normalizeAndSave(media); + media.setStatus(MediaStatus.APPROVED.getCode()); + mediaService.updateById(media); + return media; + } + /** * 修改店员资料审核 * diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImpl.java new file mode 100644 index 0000000..f1a974d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImpl.java @@ -0,0 +1,280 @@ +package com.starry.admin.modules.clerk.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState; +import com.starry.admin.modules.clerk.enums.ClerkMediaUsage; +import com.starry.admin.modules.clerk.mapper.PlayClerkMediaAssetMapper; +import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService; +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import com.starry.admin.modules.media.service.IPlayMediaService; +import com.starry.common.utils.IdUtils; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PlayClerkMediaAssetServiceImpl extends ServiceImpl + implements IPlayClerkMediaAssetService { + + @Resource + private IPlayMediaService mediaService; + + @Override + @Transactional(rollbackFor = Exception.class) + public PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId, + ClerkMediaUsage usage) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId) + .eq(StrUtil.isNotBlank(tenantId), PlayClerkMediaAssetEntity::getTenantId, tenantId) + .eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode()) + .eq(PlayClerkMediaAssetEntity::getMediaId, mediaId); + PlayClerkMediaAssetEntity existing = this.getOne(wrapper, false); + if (existing != null) { + if (StrUtil.isBlank(existing.getTenantId()) && StrUtil.isNotBlank(tenantId)) { + existing.setTenantId(tenantId); + } + if (Boolean.TRUE.equals(existing.getDeleted())) { + existing.setDeleted(false); + } + existing.setReviewState(ClerkMediaReviewState.DRAFT.getCode()); + if (existing.getOrderIndex() == null) { + existing.setOrderIndex(resolveNextOrderIndex(clerkId, usage)); + } + this.updateById(existing); + return existing; + } + + PlayClerkMediaAssetEntity entity = new PlayClerkMediaAssetEntity(); + entity.setId(IdUtils.getUuid()); + entity.setClerkId(clerkId); + entity.setTenantId(tenantId); + entity.setMediaId(mediaId); + entity.setUsage(usage.getCode()); + entity.setReviewState(ClerkMediaReviewState.DRAFT.getCode()); + entity.setOrderIndex(resolveNextOrderIndex(clerkId, usage)); + entity.setDeleted(false); + this.save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitWithOrder(String clerkId, ClerkMediaUsage usage, List mediaIds) { + List ordered = distinctMediaIds(mediaIds); + List assets = listActiveByUsage(clerkId, usage); + if (CollectionUtil.isEmpty(assets)) { + return; + } + Map assetsByMediaId = assets.stream() + .collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item)); + List updates = new ArrayList<>(); + int order = 0; + for (String mediaId : ordered) { + PlayClerkMediaAssetEntity asset = assetsByMediaId.get(mediaId); + if (asset == null) { + continue; + } + asset.setOrderIndex(order++); + asset.setReviewState(ClerkMediaReviewState.PENDING.getCode()); + asset.setSubmittedTime(LocalDateTime.now()); + updates.add(asset); + } + + Set keepSet = ordered.stream().collect(Collectors.toSet()); + for (PlayClerkMediaAssetEntity asset : assets) { + if (!keepSet.contains(asset.getMediaId())) { + asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode()); + asset.setOrderIndex(0); + updates.add(asset); + } + } + if (CollectionUtil.isNotEmpty(updates)) { + this.updateBatchById(updates); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void reorder(String clerkId, ClerkMediaUsage usage, List mediaIds) { + List ordered = distinctMediaIds(mediaIds); + if (CollectionUtil.isEmpty(ordered)) { + return; + } + List assets = listActiveByUsage(clerkId, usage); + if (CollectionUtil.isEmpty(assets)) { + return; + } + Map assetsByMediaId = assets.stream() + .collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item)); + List updates = new ArrayList<>(); + int order = 0; + for (String mediaId : ordered) { + PlayClerkMediaAssetEntity asset = assetsByMediaId.get(mediaId); + if (asset == null) { + continue; + } + if (!Objects.equals(asset.getOrderIndex(), order)) { + asset.setOrderIndex(order); + updates.add(asset); + } + order++; + } + if (CollectionUtil.isNotEmpty(updates)) { + this.updateBatchById(updates); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void softDelete(String clerkId, String mediaId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId) + .eq(PlayClerkMediaAssetEntity::getMediaId, mediaId) + .eq(PlayClerkMediaAssetEntity::getDeleted, false); + PlayClerkMediaAssetEntity asset = this.getOne(wrapper, false); + if (asset == null) { + return; + } + asset.setDeleted(true); + asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode()); + this.updateById(asset); + } + + @Override + public List listByState(String clerkId, ClerkMediaUsage usage, + Collection states) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId) + .eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode()) + .eq(PlayClerkMediaAssetEntity::getDeleted, false) + .orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex) + .orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime); + if (CollectionUtil.isNotEmpty(states)) { + wrapper.in(PlayClerkMediaAssetEntity::getReviewState, + states.stream().map(ClerkMediaReviewState::getCode).collect(Collectors.toList())); + } + return this.list(wrapper); + } + + @Override + public List listActiveByUsage(String clerkId, ClerkMediaUsage usage) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId) + .eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode()) + .eq(PlayClerkMediaAssetEntity::getDeleted, false) + .orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex) + .orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime); + return this.list(wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void applyReviewDecision(String clerkId, ClerkMediaUsage usage, List approvedValues, + String reviewRecordId, String note) { + List assets = listActiveByUsage(clerkId, usage); + if (CollectionUtil.isEmpty(assets)) { + return; + } + List normalized = distinctMediaIds(approvedValues); + Map byMediaId = assets.stream() + .collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item)); + Map byUrl = buildAssetByUrlMap(assets); + + List updates = new ArrayList<>(); + Set approvedAssetIds = new java.util.HashSet<>(); + int order = 0; + for (String value : normalized) { + PlayClerkMediaAssetEntity asset = byMediaId.get(value); + if (asset == null) { + asset = byUrl.get(value); + } + if (asset == null) { + continue; + } + asset.setReviewState(ClerkMediaReviewState.APPROVED.getCode()); + asset.setOrderIndex(order++); + asset.setReviewRecordId(reviewRecordId); + if (StrUtil.isNotBlank(note)) { + asset.setNote(note); + } + updates.add(asset); + approvedAssetIds.add(asset.getId()); + } + + for (PlayClerkMediaAssetEntity asset : assets) { + if (approvedAssetIds.contains(asset.getId())) { + continue; + } + asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode()); + asset.setReviewRecordId(reviewRecordId); + updates.add(asset); + } + + if (CollectionUtil.isNotEmpty(updates)) { + this.updateBatchById(updates); + } + } + + private int resolveNextOrderIndex(String clerkId, ClerkMediaUsage usage) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId) + .eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode()) + .eq(PlayClerkMediaAssetEntity::getDeleted, false) + .orderByDesc(PlayClerkMediaAssetEntity::getOrderIndex) + .last("limit 1"); + PlayClerkMediaAssetEntity last = this.getOne(wrapper, false); + if (last == null || last.getOrderIndex() == null) { + return 0; + } + return last.getOrderIndex() + 1; + } + + private List distinctMediaIds(List mediaIds) { + if (CollectionUtil.isEmpty(mediaIds)) { + return Collections.emptyList(); + } + return mediaIds.stream() + .filter(StrUtil::isNotBlank) + .map(String::trim) + .distinct() + .collect(Collectors.toList()); + } + + private Map buildAssetByUrlMap(List assets) { + if (CollectionUtil.isEmpty(assets)) { + return Collections.emptyMap(); + } + List mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).collect(Collectors.toList()); + List mediaList = mediaService.listByIds(mediaIds); + if (CollectionUtil.isEmpty(mediaList)) { + return Collections.emptyMap(); + } + Map mediaIdToUrl = mediaList.stream() + .filter(item -> StrUtil.isNotBlank(item.getUrl())) + .collect(Collectors.toMap(PlayMediaEntity::getId, PlayMediaEntity::getUrl, (left, right) -> left)); + Map map = new HashMap<>(); + for (PlayClerkMediaAssetEntity asset : assets) { + String url = mediaIdToUrl.get(asset.getMediaId()); + if (StrUtil.isNotBlank(url)) { + map.put(url, asset); + } + } + return map; + } + +} 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 9620a2a..d005604 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 @@ -1,5 +1,6 @@ package com.starry.admin.modules.clerk.service.impl; +import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -12,10 +13,13 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.starry.admin.common.component.JwtToken; import com.starry.admin.common.domain.LoginUser; import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState; +import com.starry.admin.modules.clerk.enums.ClerkMediaUsage; import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper; import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; +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.entity.PlayClerkUserQueryVo; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReturnVo; @@ -29,9 +33,13 @@ import com.starry.admin.modules.clerk.module.vo.PlayClerkUnsettledWagesInfoRetur import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; +import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService; +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import com.starry.admin.modules.media.enums.MediaStatus; +import com.starry.admin.modules.media.service.IPlayMediaService; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity; @@ -43,7 +51,9 @@ import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService import com.starry.admin.modules.personnel.service.IPlayPersonnelWaiterInfoService; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo; import com.starry.admin.modules.system.service.LoginService; +import com.starry.admin.modules.weichat.assembler.ClerkMediaAssembler; 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.PlayClerkUserInfoQueryVo; import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo; import com.starry.admin.utils.SecurityUtils; @@ -53,6 +63,7 @@ import com.starry.common.utils.StringUtils; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -86,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl mediaList = loadMediaForClerk(userInfo.getId(), true); + result.setMediaList(mergeLegacyAlbum(userInfo.getAlbum(), mediaList)); + result.setAlbum(CollectionUtil.isEmpty(userInfo.getAlbum()) ? new ArrayList<>() : new ArrayList<>(userInfo.getAlbum())); return result; } @@ -333,22 +351,27 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl rawPage = this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper); - if (rawPage != null && rawPage.getRecords() != null) { + IPage pageResult = this.baseMapper.selectJoinPage(page, + PlayClerkUserInfoResultVo.class, lambdaQueryWrapper); + if (pageResult != null && pageResult.getRecords() != null) { List deduped = new ArrayList<>(); Set seen = new HashSet<>(); - for (PlayClerkUserInfoResultVo record : rawPage.getRecords()) { + for (PlayClerkUserInfoResultVo record : pageResult.getRecords()) { String id = record.getId(); if (id == null || !seen.add(id)) { continue; } deduped.add(record); } - rawPage.setRecords(deduped); + pageResult.setRecords(deduped); } - return rawPage; + if (pageResult != null) { + attachMediaToResultVos(pageResult.getRecords(), false); + } + return pageResult; } @Override @@ -500,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl listWithAlbumIgnoringTenant() { + return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant(); + } + @Override public JSONObject getPcData(PlayClerkUserInfoEntity entity) { JSONObject data = new JSONObject(); @@ -628,4 +657,97 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl records, boolean includePending) { + if (CollectionUtil.isEmpty(records)) { + return; + } + Map> mediaMap = resolveMediaByAssets( + records.stream().map(PlayClerkUserInfoResultVo::getId).collect(Collectors.toList()), includePending); + for (PlayClerkUserInfoResultVo record : records) { + List mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList())); + record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList)); + } + } + + private void attachMediaToAdminVos(List records) { + if (CollectionUtil.isEmpty(records)) { + return; + } + Map> mediaMap = resolveMediaByAssets( + records.stream().map(PlayClerkUserReturnVo::getId).collect(Collectors.toList()), true); + for (PlayClerkUserReturnVo record : records) { + List mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList())); + record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList)); + } + } + + private List loadMediaForClerk(String clerkId, boolean includePending) { + Map> mediaMap = resolveMediaByAssets(Collections.singletonList(clerkId), includePending); + return new ArrayList<>(mediaMap.getOrDefault(clerkId, Collections.emptyList())); + } + + private Map> resolveMediaByAssets(List clerkIds, boolean includePending) { + if (CollectionUtil.isEmpty(clerkIds)) { + return Collections.emptyMap(); + } + + List targetStates = includePending + ? Arrays.asList(ClerkMediaReviewState.APPROVED, ClerkMediaReviewState.PENDING, + ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.REJECTED) + : Collections.singletonList(ClerkMediaReviewState.APPROVED); + + List assets = clerkMediaAssetService.lambdaQuery() + .in(PlayClerkMediaAssetEntity::getClerkId, clerkIds) + .eq(PlayClerkMediaAssetEntity::getUsage, ClerkMediaUsage.PROFILE.getCode()) + .eq(PlayClerkMediaAssetEntity::getDeleted, false) + .in(CollectionUtil.isNotEmpty(targetStates), PlayClerkMediaAssetEntity::getReviewState, + targetStates.stream().map(ClerkMediaReviewState::getCode).collect(Collectors.toList())) + .orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex) + .orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime) + .list(); + if (CollectionUtil.isEmpty(assets)) { + Map> empty = new HashMap<>(); + clerkIds.forEach(id -> empty.put(id, Collections.emptyList())); + return empty; + } + + List mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct() + .collect(Collectors.toList()); + Map mediaById = CollectionUtil.isEmpty(mediaIds) + ? Collections.emptyMap() + : mediaService.listByIds(mediaIds).stream() + .collect(Collectors.toMap(PlayMediaEntity::getId, item -> item, (left, right) -> left)); + + Map> groupedAssets = assets.stream() + .collect(Collectors.groupingBy(PlayClerkMediaAssetEntity::getClerkId)); + + Map> result = new HashMap<>(groupedAssets.size()); + groupedAssets.forEach((clerkId, assetList) -> result.put(clerkId, ClerkMediaAssembler.toVoList(assetList, mediaById))); + + clerkIds.forEach(id -> result.computeIfAbsent(id, key -> Collections.emptyList())); + return result; + } + + static List mergeLegacyAlbum(List legacyAlbum, List destination) { + if (CollectionUtil.isEmpty(legacyAlbum)) { + return destination; + } + Set existingUrls = destination.stream() + .map(MediaVo::getUrl) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + for (String url : legacyAlbum) { + if (StrUtil.isBlank(url) || !existingUrls.add(url)) { + continue; + } + MediaVo legacyVo = new MediaVo(); + legacyVo.setId(url); + legacyVo.setUrl(url); + legacyVo.setUsage(ClerkMediaUsage.PROFILE.getCode()); + legacyVo.setStatus(MediaStatus.READY.getCode()); + legacyVo.setReviewState(ClerkMediaReviewState.APPROVED.getCode()); + destination.add(legacyVo); + } + return destination; + } } 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 new file mode 100644 index 0000000..bc04d81 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/task/ClerkAlbumMigrationRunner.java @@ -0,0 +1,141 @@ +package com.starry.admin.modules.clerk.task; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.starry.admin.modules.clerk.enums.ClerkMediaUsage; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +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.MediaKind; +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.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; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 一次性迁移旧相册数据到媒资表。启用方式:启动时配置 + * {@code clerk.media.migration-enabled=true}。 + */ +@Component +@ConditionalOnProperty(prefix = "clerk.media", name = "migration-enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class ClerkAlbumMigrationRunner implements ApplicationRunner { + + private final IPlayClerkUserInfoService clerkUserInfoService; + private final IPlayMediaService mediaService; + private final IPlayClerkMediaAssetService clerkMediaAssetService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void run(ApplicationArguments args) { + log.info("[ClerkAlbumMigration] start migration from legacy album column"); + List candidates = clerkUserInfoService.listWithAlbumIgnoringTenant(); + + if (CollectionUtil.isEmpty(candidates)) { + log.info("[ClerkAlbumMigration] no clerk records with legacy album found, skip"); + return; + } + + AtomicInteger migratedOwners = new AtomicInteger(); + AtomicInteger migratedMedia = new AtomicInteger(); + String originalTenantId = SecurityUtils.getTenantId(); + for (PlayClerkUserInfoEntity clerk : candidates) { + String tenantId = StrUtil.blankToDefault(clerk.getTenantId(), originalTenantId); + SecurityUtils.setTenantId(tenantId); + try { + List album = clerk.getAlbum(); + if (CollectionUtil.isEmpty(album)) { + continue; + } + List sanitizedAlbum = album.stream() + .filter(StrUtil::isNotBlank) + .map(String::trim) + .distinct() + .collect(Collectors.toList()); + if (CollectionUtil.isEmpty(sanitizedAlbum)) { + continue; + } + List approvedMediaIds = new ArrayList<>(); + for (String value : sanitizedAlbum) { + PlayMediaEntity media = resolveMediaEntity(clerk, value); + if (media == null) { + continue; + } + if (!MediaStatus.APPROVED.getCode().equals(media.getStatus())) { + media.setStatus(MediaStatus.APPROVED.getCode()); + mediaService.updateById(media); + } + clerkMediaAssetService.linkDraftAsset(clerk.getTenantId(), clerk.getId(), media.getId(), + ClerkMediaUsage.PROFILE); + approvedMediaIds.add(media.getId()); + } + + 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(), + clerk.getId()); + } finally { + SecurityUtils.setTenantId(originalTenantId); + } + } + log.info("[ClerkAlbumMigration] completed, owners migrated: {}, media migrated: {}", migratedOwners.get(), + migratedMedia.get()); + } + + private PlayMediaEntity resolveMediaEntity(PlayClerkUserInfoEntity clerk, String value) { + if (StrUtil.isBlank(value)) { + return null; + } + PlayMediaEntity byId = mediaService.getById(value); + if (byId != null) { + return byId; + } + PlayMediaEntity byUrl = mediaService.lambdaQuery() + .eq(PlayMediaEntity::getOwnerType, MediaOwnerType.CLERK) + .eq(PlayMediaEntity::getOwnerId, clerk.getId()) + .eq(PlayMediaEntity::getUrl, value) + .last("limit 1") + .one(); + if (byUrl != null) { + return byUrl; + } + PlayMediaEntity media = new PlayMediaEntity(); + media.setId(IdUtils.getUuid()); + media.setTenantId(clerk.getTenantId()); + media.setOwnerType(MediaOwnerType.CLERK); + media.setOwnerId(clerk.getId()); + media.setKind(MediaKind.IMAGE.getCode()); + media.setStatus(MediaStatus.APPROVED.getCode()); + media.setUrl(value); + Map metadata = new HashMap<>(); + metadata.put("legacySource", "album_migration"); + media.setMetadata(metadata); + mediaService.normalizeAndSave(media); + return media; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/entity/PlayMediaEntity.java b/play-admin/src/main/java/com/starry/admin/modules/media/entity/PlayMediaEntity.java new file mode 100644 index 0000000..d9bc046 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/entity/PlayMediaEntity.java @@ -0,0 +1,92 @@ +package com.starry.admin.modules.media.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import java.util.Date; +import java.util.Map; +import lombok.Data; + +/** + * 媒资表 play_media + * + *

存储各类业务(店员、顾客等)的图片/视频。

+ */ +@Data +@TableName(value = "play_media", autoResultMap = true) +public class PlayMediaEntity { + + @TableId + private String id; + + /** + * 租户ID + */ + private String tenantId; + + /** + * 归属业务类型,例如 clerk/custom/order + */ + private String ownerType; + + /** + * 归属业务主键,例如店员ID + */ + private String ownerId; + + /** + * 媒资类型 image / video + */ + private String kind; + + /** + * 媒资状态 uploaded / processing / ready / approved / rejected + */ + private String status; + + /** + * 资源地址 + */ + private String url; + + /** + * 视频封面地址 + */ + private String coverUrl; + + /** + * 时长(毫秒) + */ + private Long durationMs; + + /** + * 媒资宽度 + */ + private Integer width; + + /** + * 媒资高度 + */ + private Integer height; + + /** + * 文件大小(字节) + */ + private Long sizeBytes; + + /** + * 排序序号,从 0 开始 + */ + private Integer orderIndex; + + /** + * 扩展字段 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map metadata; + + private Date createdTime; + + private Date updatedTime; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaKind.java b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaKind.java new file mode 100644 index 0000000..65e4eac --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaKind.java @@ -0,0 +1,33 @@ +package com.starry.admin.modules.media.enums; + +import lombok.Getter; + +@Getter +public enum MediaKind { + + IMAGE("image"), + VIDEO("video"); + + private final String code; + + MediaKind(String code) { + this.code = code; + } + + public static boolean isVideo(String value) { + return VIDEO.code.equalsIgnoreCase(value); + } + + public static boolean isImage(String value) { + return IMAGE.code.equalsIgnoreCase(value); + } + + public static MediaKind fromCode(String value) { + for (MediaKind kind : values()) { + if (kind.code.equalsIgnoreCase(value)) { + return kind; + } + } + throw new IllegalArgumentException("Unsupported media kind: " + value); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaOwnerType.java b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaOwnerType.java new file mode 100644 index 0000000..e47bd1e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaOwnerType.java @@ -0,0 +1,9 @@ +package com.starry.admin.modules.media.enums; + +public final class MediaOwnerType { + + private MediaOwnerType() { + } + + public static final String CLERK = "clerk"; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaStatus.java b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaStatus.java new file mode 100644 index 0000000..e2891b2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/enums/MediaStatus.java @@ -0,0 +1,22 @@ +package com.starry.admin.modules.media.enums; + +import lombok.Getter; + +@Getter +public enum MediaStatus { + UPLOADED("uploaded"), + PROCESSING("processing"), + READY("ready"), + APPROVED("approved"), + REJECTED("rejected"); + + private final String code; + + MediaStatus(String code) { + this.code = code; + } + + public static boolean isTerminal(String value) { + return APPROVED.code.equalsIgnoreCase(value) || REJECTED.code.equalsIgnoreCase(value); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/mapper/PlayMediaMapper.java b/play-admin/src/main/java/com/starry/admin/modules/media/mapper/PlayMediaMapper.java new file mode 100644 index 0000000..5fe8f5f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/mapper/PlayMediaMapper.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.media.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.media.entity.PlayMediaEntity; + +public interface PlayMediaMapper extends BaseMapper { + +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/media/service/IPlayMediaService.java b/play-admin/src/main/java/com/starry/admin/modules/media/service/IPlayMediaService.java new file mode 100644 index 0000000..ef43ead --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/service/IPlayMediaService.java @@ -0,0 +1,21 @@ +package com.starry.admin.modules.media.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import java.util.Collection; +import java.util.List; + +public interface IPlayMediaService extends IService { + + List listByOwner(String ownerType, String ownerId); + + List listByOwner(String ownerType, String ownerId, Collection statuses); + + List listApprovedByOwner(String ownerType, String ownerId); + + PlayMediaEntity normalizeAndSave(PlayMediaEntity entity); + + void updateOrder(String ownerType, String ownerId, List orderedIds); + + void softDelete(String ownerType, String ownerId, String mediaId); +} 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 new file mode 100644 index 0000000..7e3714a --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/media/service/impl/PlayMediaServiceImpl.java @@ -0,0 +1,136 @@ +package com.starry.admin.modules.media.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.common.exception.CustomException; +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.mapper.PlayMediaMapper; +import com.starry.admin.modules.media.service.IPlayMediaService; +import java.util.*; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PlayMediaServiceImpl extends ServiceImpl + implements IPlayMediaService { + + @Override + public List listByOwner(String ownerType, String ownerId) { + return listByOwner(ownerType, ownerId, null); + } + + @Override + public List listByOwner(String ownerType, String ownerId, Collection statuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayMediaEntity::getOwnerType, ownerType) + .eq(PlayMediaEntity::getOwnerId, ownerId) + .orderByAsc(PlayMediaEntity::getOrderIndex) + .orderByDesc(PlayMediaEntity::getCreatedTime); + if (CollectionUtil.isNotEmpty(statuses)) { + wrapper.in(PlayMediaEntity::getStatus, statuses); + } + return this.list(wrapper); + } + + @Override + public List listApprovedByOwner(String ownerType, String ownerId) { + return listByOwner(ownerType, ownerId, Collections.singleton(MediaStatus.APPROVED.getCode())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PlayMediaEntity normalizeAndSave(PlayMediaEntity entity) { + Assert.notNull(entity, "媒资信息不能为空"); + Assert.isTrue(StrUtil.isNotBlank(entity.getOwnerId()), "媒资归属ID不能为空"); + // ownerType 默认 clerk + if (StrUtil.isBlank(entity.getOwnerType())) { + entity.setOwnerType(MediaOwnerType.CLERK); + } + if (entity.getOrderIndex() == null) { + entity.setOrderIndex(resolveNextOrderIndex(entity.getOwnerType(), entity.getOwnerId())); + } + if (StrUtil.isBlank(entity.getStatus())) { + entity.setStatus(MediaStatus.UPLOADED.getCode()); + } + boolean saved = this.save(entity); + if (!saved) { + throw new CustomException("媒资保存失败"); + } + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOrder(String ownerType, String ownerId, List orderedIds) { + List mediaList = listByOwner(ownerType, ownerId); + if (CollectionUtil.isEmpty(mediaList)) { + return; + } + Map mediaById = mediaList.stream() + .collect(Collectors.toMap(PlayMediaEntity::getId, item -> item)); + Set keepSet = new LinkedHashSet<>(); + if (CollectionUtil.isNotEmpty(orderedIds)) { + keepSet.addAll(orderedIds); + } + List updates = new ArrayList<>(); + int index = 0; + for (String mediaId : keepSet) { + PlayMediaEntity entity = mediaById.get(mediaId); + if (entity == null) { + throw new CustomException("媒资不存在或已被删除"); + } + entity.setOrderIndex(index++); + if (MediaStatus.REJECTED.getCode().equals(entity.getStatus())) { + entity.setStatus(MediaStatus.READY.getCode()); + } + updates.add(entity); + } + // 其他未保留的标记为 rejected + for (PlayMediaEntity entity : mediaList) { + if (!keepSet.contains(entity.getId()) + && !MediaStatus.REJECTED.getCode().equals(entity.getStatus())) { + entity.setStatus(MediaStatus.REJECTED.getCode()); + entity.setOrderIndex(0); + updates.add(entity); + } + } + if (CollectionUtil.isNotEmpty(updates)) { + this.updateBatchById(updates); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void softDelete(String ownerType, String ownerId, String mediaId) { + PlayMediaEntity entity = this.getById(mediaId); + if (entity == null) { + return; + } + if (!ownerType.equals(entity.getOwnerType()) || !ownerId.equals(entity.getOwnerId())) { + throw new CustomException("无权删除该媒资"); + } + entity.setStatus(MediaStatus.REJECTED.getCode()); + entity.setOrderIndex(0); + this.updateById(entity); + } + + private int resolveNextOrderIndex(String ownerType, String ownerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PlayMediaEntity::getOwnerType, ownerType) + .eq(PlayMediaEntity::getOwnerId, ownerId) + .ne(PlayMediaEntity::getStatus, MediaStatus.REJECTED.getCode()) + .orderByDesc(PlayMediaEntity::getOrderIndex) + .last("limit 1"); + PlayMediaEntity last = this.getOne(wrapper, false); + if (last == null || last.getOrderIndex() == null) { + return 0; + } + return last.getOrderIndex() + 1; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/ClerkMediaAssembler.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/ClerkMediaAssembler.java new file mode 100644 index 0000000..c6bae24 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/ClerkMediaAssembler.java @@ -0,0 +1,52 @@ +package com.starry.admin.modules.weichat.assembler; + +import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity; +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public final class ClerkMediaAssembler { + + private ClerkMediaAssembler() { + } + + public static MediaVo toVo(PlayMediaEntity media, PlayClerkMediaAssetEntity asset) { + if (media == null || asset == null || Boolean.TRUE.equals(asset.getDeleted())) { + return null; + } + MediaVo vo = new MediaVo(); + vo.setId(media.getId()); + vo.setMediaId(media.getId()); + vo.setAssetId(asset.getId()); + vo.setKind(media.getKind()); + vo.setStatus(media.getStatus()); + vo.setUrl(media.getUrl()); + vo.setCoverUrl(media.getCoverUrl()); + vo.setDurationMs(media.getDurationMs()); + vo.setWidth(media.getWidth()); + vo.setHeight(media.getHeight()); + vo.setSizeBytes(media.getSizeBytes()); + vo.setOrderIndex(asset.getOrderIndex()); + vo.setMetadata(media.getMetadata()); + vo.setUsage(asset.getUsage()); + vo.setReviewState(asset.getReviewState()); + vo.setSubmittedTime(asset.getSubmittedTime()); + vo.setReviewNote(asset.getNote()); + return vo; + } + + public static List toVoList(List assets, + Map mediaById) { + if (assets == null || assets.isEmpty()) { + return Collections.emptyList(); + } + return assets.stream() + .map(asset -> toVo(mediaById.get(asset.getMediaId()), asset)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/MediaAssembler.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/MediaAssembler.java new file mode 100644 index 0000000..4483651 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/assembler/MediaAssembler.java @@ -0,0 +1,44 @@ +package com.starry.admin.modules.weichat.assembler; + +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public final class MediaAssembler { + + private MediaAssembler() { + } + + public static MediaVo toVo(PlayMediaEntity entity) { + if (entity == null) { + return null; + } + MediaVo vo = new MediaVo(); + vo.setId(entity.getId()); + vo.setMediaId(entity.getId()); + vo.setKind(entity.getKind()); + vo.setStatus(entity.getStatus()); + vo.setUrl(entity.getUrl()); + vo.setCoverUrl(entity.getCoverUrl()); + vo.setDurationMs(entity.getDurationMs()); + vo.setWidth(entity.getWidth()); + vo.setHeight(entity.getHeight()); + vo.setSizeBytes(entity.getSizeBytes()); + vo.setOrderIndex(entity.getOrderIndex()); + vo.setMetadata(entity.getMetadata()); + return vo; + } + + public static List toVoList(List entities) { + if (entities == null) { + return Collections.emptyList(); + } + return entities.stream() + .filter(Objects::nonNull) + .map(MediaAssembler::toVo) + .collect(Collectors.toList()); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java index 72458a3..da7b3ed 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java @@ -41,6 +41,7 @@ import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SmsUtils; import com.starry.common.redis.RedisCache; import com.starry.common.result.R; +import com.starry.common.result.TypedR; import com.starry.common.utils.ConvertUtil; import com.starry.common.utils.StringUtils; import com.starry.common.utils.VerificationCodeUtils; @@ -394,10 +395,10 @@ public class WxClerkController { * @return 店员列表 */ @PostMapping("/user/queryByPage") - public R queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) { + public TypedR> queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) { IPage page = playClerkUserInfoService.selectByPage(vo, customUserService.getLoginUserId()); - return R.ok(page); + return TypedR.ok(page); } /** diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java new file mode 100644 index 0000000..b8b86a1 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java @@ -0,0 +1,121 @@ +package com.starry.admin.modules.weichat.controller; + +import cn.hutool.core.collection.CollUtil; +import com.starry.admin.common.aspect.ClerkUserLogin; +import com.starry.admin.common.conf.ThreadLocalRequestDetail; +import com.starry.admin.common.exception.CustomException; +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.IPlayClerkMediaAssetService; +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.assembler.ClerkMediaAssembler; +import com.starry.admin.modules.weichat.entity.clerk.MediaOrderRequest; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import com.starry.admin.modules.weichat.service.MediaUploadService; +import com.starry.common.result.R; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Api(tags = "店员媒资接口") +@RestController +@RequestMapping("/wx/clerk/media") +@Validated +@RequiredArgsConstructor +public class WxClerkMediaController { + + private final MediaUploadService mediaUploadService; + private final IPlayMediaService mediaService; + private final IPlayClerkMediaAssetService clerkMediaAssetService; + + @ApiOperation("上传媒资(图片/视频)") + @PostMapping("/upload") + @ClerkUserLogin + public R upload(@RequestParam("file") MultipartFile file, + @RequestParam(value = "usage", required = false) String usageCode) { + PlayClerkUserInfoEntity clerkInfo = requireClerkInfo(); + MediaVo vo = mediaUploadService.upload(file, clerkInfo, ClerkMediaUsage.fromCode(usageCode)); + return R.ok(vo); + } + + @ApiOperation("更新媒资顺序并提交保留列表") + @PutMapping("/order") + @ClerkUserLogin + public R updateOrder(@Valid @RequestBody MediaOrderRequest request) { + PlayClerkUserInfoEntity clerkInfo = requireClerkInfo(); + ClerkMediaUsage usage = ClerkMediaUsage.fromCode(request.getUsage()); + List mediaIds = CollUtil.isEmpty(request.getMediaIds()) ? Collections.emptyList() + : request.getMediaIds().stream().distinct().collect(Collectors.toList()); + clerkMediaAssetService.submitWithOrder(clerkInfo.getId(), usage, mediaIds); + return R.ok(); + } + + @ApiOperation("删除媒资(软删除)") + @DeleteMapping("/{id}") + @ClerkUserLogin + public R delete(@PathVariable("id") String mediaId) { + PlayClerkUserInfoEntity clerkInfo = requireClerkInfo(); + clerkMediaAssetService.softDelete(clerkInfo.getId(), mediaId); + mediaService.softDelete(MediaOwnerType.CLERK, clerkInfo.getId(), mediaId); + return R.ok(); + } + + @ApiOperation("查询草稿媒资列表") + @GetMapping("/list") + @ClerkUserLogin + public R listDraft(@RequestParam(value = "usage", required = false) String usageCode) { + PlayClerkUserInfoEntity clerkInfo = requireClerkInfo(); + ClerkMediaUsage usage = ClerkMediaUsage.fromCode(usageCode); + List assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage, + Arrays.asList(ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.PENDING, + ClerkMediaReviewState.REJECTED)); + Map mediaMap = loadMediaMap(assets); + return R.ok(ClerkMediaAssembler.toVoList(assets, mediaMap)); + } + + @ApiOperation("查询已审核通过的媒资") + @GetMapping("/approved") + @ClerkUserLogin + public R listApproved(@RequestParam(value = "usage", required = false) String usageCode) { + PlayClerkUserInfoEntity clerkInfo = requireClerkInfo(); + ClerkMediaUsage usage = ClerkMediaUsage.fromCode(usageCode); + List assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage, + Collections.singletonList(ClerkMediaReviewState.APPROVED)); + Map mediaMap = loadMediaMap(assets); + return R.ok(ClerkMediaAssembler.toVoList(assets, mediaMap)); + } + + private PlayClerkUserInfoEntity requireClerkInfo() { + PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo(); + if (clerk == null) { + throw new CustomException("店员未登录"); + } + return clerk; + } + + private Map loadMediaMap(List assets) { + if (CollUtil.isEmpty(assets)) { + return Collections.emptyMap(); + } + List mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct() + .collect(Collectors.toList()); + List mediaList = mediaService.listByIds(mediaIds); + if (CollUtil.isEmpty(mediaList)) { + return Collections.emptyMap(); + } + return mediaList.stream().collect(Collectors.toMap(PlayMediaEntity::getId, item -> item)); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/PlayClerkUserLoginResponseVo.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/PlayClerkUserLoginResponseVo.java index d37909c..f83f5ba 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/PlayClerkUserLoginResponseVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/PlayClerkUserLoginResponseVo.java @@ -3,6 +3,7 @@ package com.starry.admin.modules.weichat.entity; import com.alibaba.fastjson2.JSONObject; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; import java.util.ArrayList; import java.util.List; import lombok.Data; @@ -45,6 +46,11 @@ public class PlayClerkUserLoginResponseVo { */ private List album = new ArrayList<>(); + /** + * 新媒资列表 + */ + private List mediaList = new ArrayList<>(); + /** * 相册是否运行编辑 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaOrderRequest.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaOrderRequest.java new file mode 100644 index 0000000..5973494 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaOrderRequest.java @@ -0,0 +1,14 @@ +package com.starry.admin.modules.weichat.entity.clerk; + +import java.util.List; +import javax.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class MediaOrderRequest { + + private String usage; + + @NotNull(message = "媒资ID列表不能为空") + private List mediaIds; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaVo.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaVo.java new file mode 100644 index 0000000..02c8528 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/MediaVo.java @@ -0,0 +1,44 @@ +package com.starry.admin.modules.weichat.entity.clerk; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.Data; + +@Data +public class MediaVo implements Serializable { + + private String id; + + private String assetId; + + private String mediaId; + + private String kind; + + private String status; + + private String url; + + private String coverUrl; + + private Long durationMs; + + private Integer width; + + private Integer height; + + private Long sizeBytes; + + private Integer orderIndex; + + private Map metadata; + + private String usage; + + private String reviewState; + + private LocalDateTime submittedTime; + + private String reviewNote; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/PlayClerkUserInfoResultVo.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/PlayClerkUserInfoResultVo.java index 5fd9587..32087d4 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/PlayClerkUserInfoResultVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/entity/clerk/PlayClerkUserInfoResultVo.java @@ -75,6 +75,12 @@ public class PlayClerkUserInfoResultVo { @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表") private List album = new ArrayList<>(); + /** + * 媒资列表 + */ + @ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据") + private List mediaList = new ArrayList<>(); + /** * 个性签名 */ 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 new file mode 100644 index 0000000..95c0e5d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java @@ -0,0 +1,287 @@ +package com.starry.admin.modules.weichat.service; + +import cn.hutool.core.io.FileTypeUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.common.oss.service.IOssFileService; +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.IPlayClerkMediaAssetService; +import com.starry.admin.modules.media.entity.PlayMediaEntity; +import com.starry.admin.modules.media.enums.MediaKind; +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.assembler.ClerkMediaAssembler; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import com.starry.common.utils.IdUtils; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import javax.imageio.ImageIO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import ws.schild.jave.Encoder; +import ws.schild.jave.MultimediaObject; +import ws.schild.jave.encode.AudioAttributes; +import ws.schild.jave.encode.EncodingAttributes; +import ws.schild.jave.encode.VideoAttributes; +import ws.schild.jave.info.MultimediaInfo; +import ws.schild.jave.info.VideoInfo; +import ws.schild.jave.info.VideoSize; + +@Service +@RequiredArgsConstructor +@Slf4j +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 String IMAGE_OUTPUT_FORMAT = "image2"; + private static final String VIDEO_OUTPUT_FORMAT = "mp4"; + + private final IOssFileService ossFileService; + private final IPlayMediaService mediaService; + private final IPlayClerkMediaAssetService clerkMediaAssetService; + + @Transactional(rollbackFor = Exception.class) + public MediaVo upload(MultipartFile file, PlayClerkUserInfoEntity clerkInfo, ClerkMediaUsage usage) { + if (file == null || file.isEmpty()) { + throw new CustomException("请选择要上传的文件"); + } + if (clerkInfo == null) { + throw new CustomException("店员信息不存在"); + } + + String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), file.getName()); + File tempFile = null; + File processedVideoFile = null; + File coverFile = null; + try { + String suffix = resolveSuffix(originalFilename); + tempFile = createTempFile("media_", suffix); + file.transferTo(tempFile); + + String detectedType = detectFileType(tempFile, file.getContentType()); + boolean isVideo = isVideoType(detectedType, file.getContentType()); + boolean isImage = isImageType(detectedType, file.getContentType()); + if (!isVideo && !isImage) { + log.warn("Unsupported media type: {} / {}", detectedType, file.getContentType()); + throw new CustomException("不支持的文件格式"); + } + + PlayMediaEntity entity = buildSkeletonEntity(file, clerkInfo, + 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); + } else { + processedVideoFile = createTempFile("media_video_", ".mp4"); + coverFile = createTempFile("media_cover_", ".jpg"); + handleVideoUpload(tempFile, processedVideoFile, coverFile, entity, clerkInfo, originalFilename); + } + entity.setStatus(MediaStatus.READY.getCode()); + mediaService.updateById(entity); + PlayClerkMediaAssetEntity asset = clerkMediaAssetService.linkDraftAsset( + clerkInfo.getTenantId(), + clerkInfo.getId(), + entity.getId(), + usage == null ? ClerkMediaUsage.PROFILE : usage); + return ClerkMediaAssembler.toVo(entity, asset); + } catch (CustomException customException) { + throw customException; + } catch (Exception ex) { + log.error("媒资上传失败", ex); + throw new CustomException("媒资上传失败,请稍后重试"); + } finally { + deleteQuietly(tempFile); + deleteQuietly(processedVideoFile); + deleteQuietly(coverFile); + } + } + + private PlayMediaEntity buildSkeletonEntity(MultipartFile file, PlayClerkUserInfoEntity clerkInfo, MediaKind kind) { + PlayMediaEntity entity = new PlayMediaEntity(); + entity.setId(IdUtils.getUuid()); + entity.setTenantId(clerkInfo.getTenantId()); + entity.setOwnerType(MediaOwnerType.CLERK); + entity.setOwnerId(clerkInfo.getId()); + entity.setKind(kind.getCode()); + entity.setStatus(MediaStatus.UPLOADED.getCode()); + entity.setSizeBytes(file.getSize()); + Map metadata = new HashMap<>(); + metadata.put("originalFilename", file.getOriginalFilename()); + metadata.put("contentType", file.getContentType()); + metadata.put("uploadTraceId", IdUtil.fastUUID()); + metadata.put("sourceSizeBytes", file.getSize()); + entity.setMetadata(metadata); + return entity; + } + + private void handleImageUpload(File tempFile, PlayMediaEntity entity, PlayClerkUserInfoEntity clerkInfo, + String originalFilename) throws IOException { + BufferedImage image = ImageIO.read(tempFile); + if (image == null) { + throw new CustomException("图片文件已损坏或格式不受支持"); + } + entity.setWidth(image.getWidth()); + entity.setHeight(image.getHeight()); + try (InputStream is = Files.newInputStream(tempFile.toPath())) { + String targetName = buildObjectName("img", originalFilename); + String url = ossFileService.upload(is, clerkInfo.getTenantId(), targetName); + entity.setUrl(url); + } + } + + private void handleVideoUpload(File sourceFile, File targetFile, File coverFile, PlayMediaEntity entity, + PlayClerkUserInfoEntity clerkInfo, String originalFilename) throws Exception { + if (entity.getSizeBytes() != null && entity.getSizeBytes() > MAX_VIDEO_BYTES) { + throw new CustomException("视频大小不能超过30MB"); + } + MultimediaObject multimediaObject = new MultimediaObject(sourceFile); + MultimediaInfo info = multimediaObject.getInfo(); + if (info == null || info.getVideo() == null) { + throw new CustomException("无法读取视频信息"); + } + long durationMs = info.getDuration(); + if (durationMs > MAX_VIDEO_DURATION_MS) { + throw new CustomException("视频时长不能超过30秒"); + } + VideoInfo videoInfo = info.getVideo(); + VideoSize size = videoInfo.getSize(); + if (size != null) { + entity.setWidth(size.getWidth()); + entity.setHeight(size.getHeight()); + } + entity.setDurationMs(durationMs); + + AudioAttributes audioAttrs = new AudioAttributes(); + audioAttrs.setCodec("aac"); + audioAttrs.setBitRate(128_000); + audioAttrs.setChannels(2); + audioAttrs.setSamplingRate(44_100); + + VideoAttributes videoAttrs = new VideoAttributes(); + videoAttrs.setCodec("h264"); + videoAttrs.setBitRate(1_500_000); + if (size != null) { + videoAttrs.setSize(size); + } + float frameRate = videoInfo.getFrameRate(); + videoAttrs.setFrameRate(frameRate > 0 ? Math.round(frameRate) : 30); + + Encoder encoder = new Encoder(); + EncodingAttributes attrs = new EncodingAttributes(); + attrs.setOutputFormat(VIDEO_OUTPUT_FORMAT); + attrs.setAudioAttributes(audioAttrs); + attrs.setVideoAttributes(videoAttrs); + encoder.encode(multimediaObject, targetFile, attrs); + + long processedSize = targetFile.length(); + entity.setSizeBytes(processedSize); + + // 抽取首帧作为封面 + EncodingAttributes coverAttrs = new EncodingAttributes(); + VideoAttributes coverVideoAttrs = new VideoAttributes(); + coverVideoAttrs.setCodec("mjpeg"); + if (size != null) { + coverVideoAttrs.setSize(size); + } + coverAttrs.setOutputFormat(IMAGE_OUTPUT_FORMAT); + coverAttrs.setVideoAttributes(coverVideoAttrs); + coverAttrs.setDuration(0.01f); + coverAttrs.setOffset(0f); + coverAttrs.setAudioAttributes(null); + encoder.encode(new MultimediaObject(targetFile), coverFile, coverAttrs); + + try (InputStream videoIs = Files.newInputStream(targetFile.toPath()); + InputStream coverIs = Files.newInputStream(coverFile.toPath())) { + String videoName = buildObjectName("video", originalFilename); + String coverName = buildObjectName("cover", originalFilename + ".jpg"); + String videoUrl = ossFileService.upload(videoIs, clerkInfo.getTenantId(), videoName); + String coverUrl = ossFileService.upload(coverIs, clerkInfo.getTenantId(), coverName); + entity.setUrl(videoUrl); + entity.setCoverUrl(coverUrl); + } + if (entity.getMetadata() != null) { + entity.getMetadata().put("durationMs", durationMs); + } + } + + private String detectFileType(File file, String contentType) { + String type = null; + try { + type = FileTypeUtil.getType(file); + } catch (Exception ex) { + log.warn("Failed to read file type via signature, fallback to contentType: {}", contentType, ex); + } + if (StrUtil.isNotBlank(type)) { + return type.toLowerCase(Locale.ROOT); + } + if (StrUtil.isNotBlank(contentType)) { + return contentType.toLowerCase(Locale.ROOT); + } + return ""; + } + + private boolean isVideoType(String detectedType, String mime) { + if (StrUtil.isBlank(detectedType) && StrUtil.isBlank(mime)) { + return false; + } + String lower = StrUtil.blankToDefault(detectedType, ""); + if (lower.contains("mp4") || lower.contains("mov") || lower.contains("quicktime")) { + return true; + } + String mimeLower = StrUtil.blankToDefault(mime, "").toLowerCase(Locale.ROOT); + return mimeLower.startsWith("video/"); + } + + private boolean isImageType(String detectedType, String mime) { + String lower = StrUtil.blankToDefault(detectedType, ""); + if (lower.contains("jpg") || lower.contains("jpeg") || lower.contains("png") || lower.contains("gif") + || lower.contains("webp")) { + return true; + } + String mimeLower = StrUtil.blankToDefault(mime, "").toLowerCase(Locale.ROOT); + return mimeLower.startsWith("image/"); + } + + private String buildObjectName(String category, String originalFilename) { + String ext = resolveSuffix(originalFilename); + return StrUtil.join("/", "clerk", category, IdUtils.getUuid() + ext); + } + + private String resolveSuffix(String filename) { + if (StrUtil.isBlank(filename) || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.')); + } + + private void deleteQuietly(File file) { + if (file != null && file.exists()) { + FileUtil.del(file); + } + } + + private File createTempFile(String prefix, String suffix) throws IOException { + String effectiveSuffix = StrUtil.isBlank(suffix) ? ".tmp" : suffix; + return Files.createTempFile(prefix, effectiveSuffix).toFile(); + } +} diff --git a/play-admin/src/main/resources/application-dev.yml b/play-admin/src/main/resources/application-dev.yml index d74b87d..7e527ae 100644 --- a/play-admin/src/main/resources/application-dev.yml +++ b/play-admin/src/main/resources/application-dev.yml @@ -96,6 +96,10 @@ logging: org.springframework.web.servlet.DispatcherServlet: debug org.springframework.security: debug +clerk: + media: + migration-enabled: false + jwt: tokenHeader: X-Token #JWT存储的请求头 tokenHead: Bearer #JWT负载中拿到开头 @@ -117,4 +121,3 @@ xl: authCode: # 登录验证码是否开启,开发环境配置false方便测试 enable: ${XL_LOGIN_AUTHCODE_ENABLE:false} - diff --git a/play-admin/src/main/resources/db/migration/V17__create_media.sql b/play-admin/src/main/resources/db/migration/V17__create_media.sql new file mode 100644 index 0000000..25fbe3b --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V17__create_media.sql @@ -0,0 +1,43 @@ +CREATE TABLE `play_media` ( + `id` varchar(32) NOT NULL, + `tenant_id` varchar(32) NOT NULL, + `owner_type` varchar(32) NOT NULL COMMENT 'clerk/article/...', + `owner_id` varchar(32) NOT NULL, + `kind` varchar(16) NOT NULL COMMENT 'image | video', + `status` varchar(16) NOT NULL COMMENT 'uploaded|processing|ready|approved|rejected', + `url` varchar(1024) NOT NULL, + `cover_url` varchar(1024) DEFAULT NULL, + `duration_ms` bigint DEFAULT NULL, + `width` int DEFAULT NULL, + `height` int DEFAULT NULL, + `size_bytes` bigint DEFAULT NULL, + `order_index` int NOT NULL DEFAULT 0, + `metadata` json DEFAULT NULL, + `created_time` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_media_owner` (`owner_type`,`owner_id`), + KEY `idx_media_order` (`tenant_id`,`owner_type`,`owner_id`,`order_index`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `play_clerk_media_asset` ( + `id` varchar(32) NOT NULL, + `clerk_id` varchar(32) NOT NULL, + `tenant_id` varchar(32) NOT NULL, + `media_id` varchar(32) NOT NULL, + `usage` varchar(32) NOT NULL, + `review_state` varchar(16) NOT NULL, + `order_index` int NOT NULL DEFAULT 0, + `submitted_time` datetime DEFAULT NULL, + `review_record_id` varchar(32) DEFAULT NULL, + `note` varchar(255) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT 0, + `created_by` varchar(32) DEFAULT NULL, + `created_time` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` varchar(32) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_clerk_usage_media` (`clerk_id`,`usage`,`media_id`), + KEY `idx_clerk_usage_state` (`clerk_id`,`usage`,`review_state`,`deleted`), + KEY `idx_clerk_media_asset_media` (`media_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java b/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java index 50e044b..c1c9e6f 100644 --- a/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java @@ -43,8 +43,8 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest { private final List clerkIdsToCleanup = new ArrayList<>(); private int scenarioSequence = 0; private static final Comparator BACKEND_ORDERING = Comparator - .comparing(ClerkScenario::isOnline).reversed() - .thenComparing(ClerkScenario::isPinned).reversed() + .comparing(ClerkScenario::isOnline, Comparator.reverseOrder()) + .thenComparing(ClerkScenario::isPinned, Comparator.reverseOrder()) .thenComparingLong(ClerkScenario::getLevelOrder) .thenComparingInt(ClerkScenario::getSequence) .thenComparing(ClerkScenario::getId); diff --git a/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImplTest.java new file mode 100644 index 0000000..c0c051e --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkDataReviewInfoServiceImplTest.java @@ -0,0 +1,118 @@ +package com.starry.admin.modules.clerk.service.impl; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.vo.PlayClerkDataReviewStateEditVo; +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.service.IPlayMediaService; +import com.starry.common.enums.ClerkReviewState; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PlayClerkDataReviewInfoServiceImplTest { + + @Mock + private IPlayClerkUserInfoService clerkUserInfoService; + @Mock + private IPlayClerkMediaAssetService clerkMediaAssetService; + @Mock + private IPlayMediaService mediaService; + + private PlayClerkDataReviewInfoServiceImpl service; + + @BeforeEach + void setUp() { + service = spy(new PlayClerkDataReviewInfoServiceImpl()); + ReflectionTestUtils.setField(service, "playClerkUserInfoService", clerkUserInfoService); + ReflectionTestUtils.setField(service, "clerkMediaAssetService", clerkMediaAssetService); + ReflectionTestUtils.setField(service, "mediaService", mediaService); + doReturn(true).when(service).update(any(PlayClerkDataReviewInfoEntity.class)); + } + + @Test + void updateDataReviewStateShouldSynchronizeAlbumOnApproval() { + PlayClerkDataReviewInfoEntity review = buildReview("review-1", "clerk-1", "2", + Arrays.asList("media-existing", "media-new")); + doReturn(review).when(service).selectPlayClerkDataReviewInfoById("review-1"); + + PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity(); + clerk.setId("clerk-1"); + clerk.setTenantId("tenant-1"); + when(clerkUserInfoService.getById("clerk-1")).thenReturn(clerk); + + PlayMediaEntity existingMedia = new PlayMediaEntity(); + existingMedia.setId("media-existing"); + when(mediaService.getById("media-existing")).thenReturn(existingMedia); + PlayMediaEntity newMedia = new PlayMediaEntity(); + newMedia.setId("media-new"); + when(mediaService.getById("media-new")).thenReturn(newMedia); + + PlayClerkMediaAssetEntity assetStub = new PlayClerkMediaAssetEntity(); + assetStub.setId("asset-1"); + when(clerkMediaAssetService.linkDraftAsset(any(), any(), any(), any())).thenReturn(assetStub); + + PlayClerkDataReviewStateEditVo vo = new PlayClerkDataReviewStateEditVo(); + vo.setId("review-1"); + vo.setReviewState(ClerkReviewState.APPROVED); + vo.setReviewCon("ok"); + + service.updateDataReviewState(vo); + + verify(clerkMediaAssetService).linkDraftAsset(eq("tenant-1"), eq("clerk-1"), eq("media-existing"), + eq(ClerkMediaUsage.PROFILE)); + verify(clerkMediaAssetService).linkDraftAsset(eq("tenant-1"), eq("clerk-1"), eq("media-new"), eq(ClerkMediaUsage.PROFILE)); + verify(clerkMediaAssetService).applyReviewDecision(eq("clerk-1"), eq(ClerkMediaUsage.PROFILE), + eq(Arrays.asList("media-existing", "media-new")), eq("review-1"), eq("ok")); + verify(clerkUserInfoService).update(any(PlayClerkUserInfoEntity.class)); + } + + @Test + void updateDataReviewStateThrowsWhenClerkMissing() { + PlayClerkDataReviewInfoEntity review = buildReview("review-2", "ghost", "2", + Collections.singletonList("media")); + doReturn(review).when(service).selectPlayClerkDataReviewInfoById("review-2"); + when(clerkUserInfoService.getById("ghost")).thenReturn(null); + + PlayClerkDataReviewStateEditVo vo = new PlayClerkDataReviewStateEditVo(); + vo.setId("review-2"); + vo.setReviewState(ClerkReviewState.APPROVED); + + assertThatThrownBy(() -> service.updateDataReviewState(vo)) + .isInstanceOf(CustomException.class) + .hasMessageContaining("店员信息不存在"); + } + + private PlayClerkDataReviewInfoEntity buildReview(String id, String clerkId, String dataType, + java.util.List payload) { + PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity(); + entity.setId(id); + entity.setClerkId(clerkId); + entity.setDataType(dataType); + entity.setDataContent(payload); + entity.setAddTime(LocalDateTime.now()); + return entity; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImplTest.java new file mode 100644 index 0000000..130da32 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkMediaAssetServiceImplTest.java @@ -0,0 +1,128 @@ +package com.starry.admin.modules.clerk.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +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.media.entity.PlayMediaEntity; +import com.starry.admin.modules.media.service.IPlayMediaService; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PlayClerkMediaAssetServiceImplTest { + + @Mock + private IPlayMediaService mediaService; + + @Test + void submitWithOrderShouldReindexAndFlagPending() { + PlayClerkMediaAssetServiceImpl service = org.mockito.Mockito.spy(new PlayClerkMediaAssetServiceImpl()); + ReflectionTestUtils.setField(service, "mediaService", mediaService); + List assets = Arrays.asList( + buildAsset("A", "media-a", 0), + buildAsset("B", "media-b", 1), + buildAsset("C", "media-c", 2)); + + doReturn(assets).when(service).listActiveByUsage("clerk-1", ClerkMediaUsage.PROFILE); + + java.util.List> batches = new java.util.ArrayList<>(); + org.mockito.Mockito.doAnswer(inv -> { + java.util.Collection collection = inv.getArgument(0); + batches.add(new java.util.ArrayList<>(collection)); + return true; + }).when(service).updateBatchById(any()); + + service.submitWithOrder("clerk-1", ClerkMediaUsage.PROFILE, Arrays.asList("media-b", "media-a")); + + List updates = flatten(batches); + PlayClerkMediaAssetEntity b = find(updates, "B"); + PlayClerkMediaAssetEntity a = find(updates, "A"); + PlayClerkMediaAssetEntity c = find(updates, "C"); + + assertThat(b.getOrderIndex()).isEqualTo(0); + assertThat(b.getReviewState()).isEqualTo(ClerkMediaReviewState.PENDING.getCode()); + assertThat(a.getOrderIndex()).isEqualTo(1); + assertThat(a.getReviewState()).isEqualTo(ClerkMediaReviewState.PENDING.getCode()); + assertThat(c.getReviewState()).isEqualTo(ClerkMediaReviewState.REJECTED.getCode()); + } + + @Test + void applyReviewDecisionShouldApproveAndReject() { + PlayClerkMediaAssetServiceImpl service = org.mockito.Mockito.spy(new PlayClerkMediaAssetServiceImpl()); + ReflectionTestUtils.setField(service, "mediaService", mediaService); + List assets = Arrays.asList( + buildAsset("A", "media-1", 0), + buildAsset("B", "media-2", 1)); + + doReturn(assets).when(service).listActiveByUsage("clerk-1", ClerkMediaUsage.PROFILE); + when(mediaService.listByIds(any())).thenReturn(Arrays.asList( + buildMedia("media-1"), + buildMedia("media-2"))); + + java.util.List> batches = new java.util.ArrayList<>(); + org.mockito.Mockito.doAnswer(inv -> { + java.util.Collection collection = inv.getArgument(0); + batches.add(new java.util.ArrayList<>(collection)); + return true; + }).when(service).updateBatchById(any()); + + service.applyReviewDecision("clerk-1", ClerkMediaUsage.PROFILE, Arrays.asList("media-2"), "review-1", "ok"); + + List updates = flatten(batches); + PlayClerkMediaAssetEntity approved = find(updates, "B"); + PlayClerkMediaAssetEntity rejected = find(updates, "A"); + + assertThat(approved.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode()); + assertThat(approved.getOrderIndex()).isEqualTo(0); + assertThat(approved.getReviewRecordId()).isEqualTo("review-1"); + assertThat(rejected.getReviewState()).isEqualTo(ClerkMediaReviewState.REJECTED.getCode()); + } + + private static PlayClerkMediaAssetEntity buildAsset(String id, String mediaId, int order) { + PlayClerkMediaAssetEntity entity = new PlayClerkMediaAssetEntity(); + entity.setId(id); + entity.setClerkId("clerk-1"); + entity.setTenantId("tenant-1"); + entity.setMediaId(mediaId); + entity.setUsage(ClerkMediaUsage.PROFILE.getCode()); + entity.setReviewState(ClerkMediaReviewState.DRAFT.getCode()); + entity.setOrderIndex(order); + entity.setSubmittedTime(LocalDateTime.now()); + return entity; + } + + private static PlayMediaEntity buildMedia(String id) { + PlayMediaEntity media = new PlayMediaEntity(); + media.setId(id); + media.setUrl("url-" + id); + return media; + } + + private static PlayClerkMediaAssetEntity find(List updates, String id) { + return updates.stream() + .filter(asset -> id.equals(asset.getId())) + .findFirst() + .orElseThrow(); + } + + @SuppressWarnings("unchecked") + private static List flatten(List> captured) { + return captured.stream() + .flatMap(List::stream) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImplTest.java new file mode 100644 index 0000000..d3869cc --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/clerk/service/impl/PlayClerkUserInfoServiceImplTest.java @@ -0,0 +1,27 @@ +package com.starry.admin.modules.clerk.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PlayClerkUserInfoServiceImplTest { + + @Test + void mergeLegacyAlbumAppendsUniqueUrls() { + List legacy = Arrays.asList("https://oss/1.png", " ", "https://oss/2.png", "https://oss/1.png"); + List destination = new ArrayList<>(); + MediaVo existing = new MediaVo(); + existing.setUrl("https://oss/2.png"); + destination.add(existing); + + List merged = PlayClerkUserInfoServiceImpl.mergeLegacyAlbum(legacy, destination); + + assertThat(merged).hasSize(2); + assertThat(merged.stream().map(MediaVo::getUrl)) + .containsExactlyInAnyOrder("https://oss/2.png", "https://oss/1.png"); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/controller/WxClerkMediaControllerTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/controller/WxClerkMediaControllerTest.java new file mode 100644 index 0000000..8b28e50 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/controller/WxClerkMediaControllerTest.java @@ -0,0 +1,143 @@ +package com.starry.admin.modules.weichat.controller; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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.starry.admin.common.conf.ThreadLocalRequestDetail; +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.IPlayClerkMediaAssetService; +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.clerk.MediaOrderRequest; +import com.starry.admin.modules.weichat.entity.clerk.MediaVo; +import com.starry.admin.modules.weichat.service.MediaUploadService; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +public class WxClerkMediaControllerTest { + + @Mock + private MediaUploadService mediaUploadService; + @Mock + private IPlayMediaService mediaService; + @Mock + private IPlayClerkMediaAssetService clerkMediaAssetService; + @InjectMocks + private WxClerkMediaController controller; + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private PlayClerkUserInfoEntity clerk; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + clerk = new PlayClerkUserInfoEntity(); + clerk.setId("clerk-1"); + clerk.setTenantId("tenant-1"); + ThreadLocalRequestDetail.setRequestDetail(clerk); + } + + @AfterEach + void tearDown() { + ThreadLocalRequestDetail.remove(); + } + + @Test + void uploadShouldReturnMediaVo() throws Exception { + MediaVo vo = new MediaVo(); + vo.setId("media-1"); + vo.setUsage(ClerkMediaUsage.PROFILE.getCode()); + when(mediaUploadService.upload(any(), eq(clerk), eq(ClerkMediaUsage.PROFILE))).thenReturn(vo); + + MockMultipartFile file = new MockMultipartFile("file", "avatar.png", "image/png", new byte[] {1, 2}); + + mockMvc.perform(multipart("/wx/clerk/media/upload") + .file(file) + .param("usage", ClerkMediaUsage.PROFILE.getCode())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").value("media-1")); + } + + @Test + void updateOrderShouldDelegateToService() throws Exception { + MediaOrderRequest request = new MediaOrderRequest(); + request.setUsage(ClerkMediaUsage.PROFILE.getCode()); + request.setMediaIds(Arrays.asList("m1", "m2")); + + mockMvc.perform(put("/wx/clerk/media/order") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(clerkMediaAssetService).submitWithOrder(eq("clerk-1"), eq(ClerkMediaUsage.PROFILE), + eq(Arrays.asList("m1", "m2"))); + } + + @Test + void listApprovedShouldReturnAssemblerOutput() throws Exception { + PlayClerkMediaAssetEntity asset = new PlayClerkMediaAssetEntity(); + asset.setId("asset-1"); + asset.setClerkId("clerk-1"); + asset.setUsage(ClerkMediaUsage.PROFILE.getCode()); + asset.setMediaId("media-1"); + asset.setReviewState(ClerkMediaReviewState.APPROVED.getCode()); + asset.setCreatedTime(java.sql.Timestamp.valueOf(LocalDateTime.now())); + when(clerkMediaAssetService.listByState(eq("clerk-1"), eq(ClerkMediaUsage.PROFILE), + eq(Collections.singletonList(ClerkMediaReviewState.APPROVED)))).thenReturn( + Collections.singletonList(asset)); + + PlayMediaEntity media = new PlayMediaEntity(); + media.setId("media-1"); + media.setUrl("https://oss/mock.png"); + when(mediaService.listByIds(Collections.singletonList("media-1"))) + .thenReturn(Collections.singletonList(media)); + + mockMvc.perform(get("/wx/clerk/media/approved")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].url").value("https://oss/mock.png")); + } + + @Test + void deleteShouldInvokeServices() throws Exception { + mockMvc.perform(delete("/wx/clerk/media/{id}", "media-77")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(clerkMediaAssetService).softDelete("clerk-1", "media-77"); + verify(mediaService).softDelete(MediaOwnerType.CLERK, "clerk-1", "media-77"); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/MediaUploadServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/MediaUploadServiceTest.java new file mode 100644 index 0000000..ecca67a --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/MediaUploadServiceTest.java @@ -0,0 +1,138 @@ +package com.starry.admin.modules.weichat.service; + +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; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.common.oss.service.IOssFileService; +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.IPlayClerkMediaAssetService; +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.clerk.MediaVo; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class MediaUploadServiceTest { + + @Mock + private IOssFileService ossFileService; + @Mock + private IPlayMediaService mediaService; + @Mock + private IPlayClerkMediaAssetService clerkMediaAssetService; + + private MediaUploadService service; + + @BeforeEach + void setUp() { + service = new MediaUploadService(ossFileService, mediaService, clerkMediaAssetService); + } + + @Test + @DisplayName("上传图片时应创建媒资与草稿资产,并返回完整的 MediaVo") + void uploadImageCreatesMediaAndAsset() throws Exception { + PlayClerkUserInfoEntity clerk = buildClerk(); + 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"); + + PlayClerkMediaAssetEntity asset = new PlayClerkMediaAssetEntity(); + asset.setId("asset-1"); + asset.setUsage(ClerkMediaUsage.PROFILE.getCode()); + asset.setReviewState("draft"); + when(clerkMediaAssetService.linkDraftAsset(any(), any(), any(), any())).thenAnswer(invocation -> { + asset.setMediaId(invocation.getArgument(2)); + return asset; + }); + + MediaVo result = service.upload(file, clerk, null); + + assertThat(result).isNotNull(); + assertThat(result.getUrl()).isEqualTo("https://oss.mock/avatar.png"); + assertThat(result.getUsage()).isEqualTo(ClerkMediaUsage.PROFILE.getCode()); + assertThat(result.getAssetId()).isEqualTo("asset-1"); + + ArgumentCaptor mediaCaptor = ArgumentCaptor.forClass(PlayMediaEntity.class); + verify(mediaService).normalizeAndSave(mediaCaptor.capture()); + PlayMediaEntity persisted = mediaCaptor.getValue(); + assertThat(persisted.getOwnerType()).isEqualTo(MediaOwnerType.CLERK); + assertThat(persisted.getOwnerId()).isEqualTo(clerk.getId()); + assertThat(persisted.getMetadata()).containsKeys("originalFilename", "contentType", "uploadTraceId"); + + verify(clerkMediaAssetService).linkDraftAsset( + eq(clerk.getTenantId()), + eq(clerk.getId()), + eq(persisted.getId()), + eq(ClerkMediaUsage.PROFILE)); + } + + @Test + @DisplayName("不支援的檔案格式應回報錯誤,且不呼叫外部服務") + void uploadRejectsUnsupportedFormat() { + PlayClerkUserInfoEntity clerk = buildClerk(); + MultipartFile invalidFile = new MockMultipartFile("file", "note.txt", "text/plain", "oops".getBytes()); + + assertThatThrownBy(() -> service.upload(invalidFile, clerk, ClerkMediaUsage.PROFILE)) + .isInstanceOf(CustomException.class) + .hasMessage("不支持的文件格式"); + + verify(mediaService, never()).normalizeAndSave(any()); + verify(ossFileService, never()).upload(any(), any(), any()); + verify(clerkMediaAssetService, never()).linkDraftAsset(any(), any(), any(), any()); + } + + @Test + @DisplayName("缺少檔案或店員資訊時應回報錯誤") + void uploadRejectsMissingInputs() { + PlayClerkUserInfoEntity clerk = buildClerk(); + MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]); + + assertThatThrownBy(() -> service.upload(emptyFile, clerk, ClerkMediaUsage.PROFILE)) + .isInstanceOf(CustomException.class) + .hasMessage("请选择要上传的文件"); + + MultipartFile file = new MockMultipartFile("file", "a.png", "image/png", new byte[] {1}); + assertThatThrownBy(() -> service.upload(file, null, ClerkMediaUsage.PROFILE)) + .isInstanceOf(CustomException.class) + .hasMessage("店员信息不存在"); + } + + private MultipartFile buildImageFile(String filename, String contentType) throws IOException { + BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", baos); + return new MockMultipartFile("file", filename, contentType, baos.toByteArray()); + } + } + + private PlayClerkUserInfoEntity buildClerk() { + PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity(); + entity.setId("clerk-1"); + entity.setTenantId("tenant-1"); + return entity; + } +}