Compare commits

..

19 Commits

Author SHA1 Message Date
irving
036e8156d5 fix: allow legacy clerk album entries
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-12-05 23:31:46 -05:00
irving
6497788b64 add more logging for debugging 2025-12-05 23:15:14 -05:00
irving
132ac8796c add test
Some checks failed
Build and Push Backend / docker (push) Failing after 13s
2025-12-05 22:39:03 -05:00
irving
f2a7039a41 fix test 2025-12-05 22:24:31 -05:00
irving
21bbd0386d feat(media): refine clerk album review and tests 2025-12-05 22:16:01 -05:00
irving
e683ef6863 test(media): legacy album compatibility for user list and detail 2025-12-04 23:12:50 -05:00
irving
086aa47226 feat(media): clerk profile media flow 2025-12-04 22:27:03 -05:00
irving
8558d203af wip: media migration progress 2025-11-16 11:33:58 -05:00
irving
69909a3b83 test: 依線上優先規則調整排序驗證
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-11-14 19:37:14 -05:00
irving
d7754a66af test: 明確驗證全組合排序 2025-11-14 19:31:53 -05:00
irving
dbf1832f75 test: 覆蓋大規模店員排序情境 2025-11-14 10:35:23 -05:00
irving
e10b7bd3be feat: 線上優先排序並更新測試 2025-11-14 10:31:16 -05:00
irving
5331fd75a2 tiny fix sorting
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-14 02:00:33 -05:00
irving
5c0de2201c fix: 店員排序穩定&apitest 連線 2025-11-14 01:52:21 -05:00
irving
29f168dd67 add import 2025-11-14 01:29:37 -05:00
irving
48348609a8 fix: 店員列表排序去重 2025-11-14 01:27:29 -05:00
irving
25554bac84 test: 修復店員排序測試與收益扣回即時解鎖 2025-11-14 01:25:06 -05:00
irving
cec5e965f6 feat: 完成撤销收益扣回與限額改動 2025-11-14 00:58:12 -05:00
hucs-dev
4cd2950051 fix: 🚀解决排序问题 2025-11-14 11:32:06 +08:00
60 changed files with 4648 additions and 318 deletions

View File

@@ -1,7 +1,7 @@
version: "3.9"
services:
mysql-apitest:
image: mysql:8.0.24
image: mysql:8.0
container_name: peipei-mysql-apitest
restart: unless-stopped
environment:
@@ -22,3 +22,11 @@ services:
interval: 10s
timeout: 5s
retries: 10
redis-apitest:
image: redis:7-alpine
container_name: peipei-redis-apitest
restart: unless-stopped
command: ["redis-server", "--appendonly", "no"]
ports:
- "36379:6379"

View File

@@ -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` 不會被清除,舊客端仍能看到舊資料。

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<PlayClerkMediaAssetEntity> {
}

View File

@@ -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<PlayClerkUserInfoEntity> {
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL")
List<PlayClerkUserInfoEntity> selectAllWithAlbumIgnoringTenant();
}

View File

@@ -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<PlayClerkMediaAssetEntity> {
@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;
}

View File

@@ -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<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/**
* 个性签名
*/

View File

@@ -60,6 +60,15 @@ public class PlayClerkDataReviewReturnVo {
@ApiModelProperty(value = "资料内容", example = "[\"https://example.com/photo1.jpg\"]", notes = "资料内容,根据资料类型有不同格式")
private List<String> dataContent;
/**
* 媒资对应的视频地址(仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应)
*/
@ApiModelProperty(
value = "媒资视频地址列表",
example = "[\"https://example.com/video1.mp4\"]",
notes = "仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应")
private List<String> mediaVideoUrls;
/**
* 审核状态0未审核:1审核通过2审核不通过
*/

View File

@@ -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> {
PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId, ClerkMediaUsage usage);
void submitWithOrder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds);
void reorder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds);
void softDelete(String clerkId, String mediaId);
List<PlayClerkMediaAssetEntity> listByState(String clerkId, ClerkMediaUsage usage, Collection<ClerkMediaReviewState> states);
List<PlayClerkMediaAssetEntity> listActiveByUsage(String clerkId, ClerkMediaUsage usage);
void applyReviewDecision(String clerkId, ClerkMediaUsage usage, List<String> approvedValues, String reviewRecordId, String note);
}

View File

@@ -190,6 +190,17 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
*/
IPage<PlayClerkUserInfoResultVo> selectPlayClerkUserInfoByPage(PlayClerkUserInfoQueryVo vo);
/**
* 构建面向顾客的店员详情视图对象(包含媒资与兼容相册)。
*
* @param clerkId
* 店员ID
* @param customUserId
* 顾客ID可为空用于标记关注状态
* @return 店员详情视图对象
*/
PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId);
/**
* 确认店员处于可用状态,否则抛出异常
*
@@ -252,5 +263,12 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
List<PlayClerkUserInfoEntity> simpleList();
/**
* 查询存在相册字段数据的店员(忽略租户隔离)
*
* @return 店员集合
*/
List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant();
JSONObject getPcData(PlayClerkUserInfoEntity entity);
}

View File

@@ -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,20 +8,33 @@ 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;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkDataType;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
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 +56,12 @@ public class PlayClerkDataReviewInfoServiceImpl
@Resource
private IPlayClerkUserInfoService playClerkUserInfoService;
@Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
/**
* 查询店员资料审核
*
@@ -107,8 +127,11 @@ public class PlayClerkDataReviewInfoServiceImpl
lambdaQueryWrapper.between(PlayClerkDataReviewInfoEntity::getAddTime, vo.getAddTime().get(0),
vo.getAddTime().get(1));
}
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayClerkDataReviewReturnVo.class, lambdaQueryWrapper);
IPage<PlayClerkDataReviewReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkDataReviewReturnVo.class,
lambdaQueryWrapper);
enrichDataContentWithMediaPreview(page);
return page;
}
/**
@@ -129,6 +152,72 @@ public class PlayClerkDataReviewInfoServiceImpl
return save(playClerkDataReviewInfo);
}
/**
* 为头像 / 相册审核记录补充可预览的 URL。
*
* <p>dataContent 中现在可能是媒资 IDmediaId或历史 URL这里做一次向前兼容
* <ul>
* <li>如果是 mediaId则解析到 play_media 记录,并返回封面或原始 URL</li>
* <li>如果查不到媒资,则保留原值。</li>
* </ul>
* 这样 PC 端审核页面始终可以正确预览图片/视频。</p>
*/
private void enrichDataContentWithMediaPreview(IPage<PlayClerkDataReviewReturnVo> page) {
if (page == null || page.getRecords() == null || page.getRecords().isEmpty()) {
return;
}
for (PlayClerkDataReviewReturnVo row : page.getRecords()) {
ClerkDataType type = row.getDataTypeEnum();
if (type == null) {
continue;
}
if (type == ClerkDataType.AVATAR || type == ClerkDataType.PHOTO_ALBUM) {
List<String> content = row.getDataContent();
if (CollectionUtil.isEmpty(content)) {
continue;
}
List<String> previewUrls = new ArrayList<>();
List<String> videoUrls = new ArrayList<>();
for (String value : content) {
if (StrUtil.isBlank(value)) {
continue;
}
MediaPreviewPair pair = resolvePreviewPair(value);
if (pair == null || StrUtil.isBlank(pair.getPreviewUrl())) {
continue;
}
previewUrls.add(pair.getPreviewUrl());
videoUrls.add(pair.getVideoUrl());
}
row.setDataContent(previewUrls);
row.setMediaVideoUrls(videoUrls);
}
}
}
private MediaPreviewPair resolvePreviewPair(String value) {
if (StrUtil.isBlank(value)) {
return null;
}
PlayMediaEntity media = mediaService.getById(value);
if (media == null) {
MediaPreviewPair fallback = new MediaPreviewPair();
fallback.setPreviewUrl(value);
fallback.setVideoUrl(null);
return fallback;
}
MediaPreviewPair pair = new MediaPreviewPair();
if (MediaKind.VIDEO.getCode().equals(media.getKind())) {
String coverUrl = StrUtil.isNotBlank(media.getCoverUrl()) ? media.getCoverUrl() : media.getUrl();
pair.setPreviewUrl(coverUrl);
pair.setVideoUrl(media.getUrl());
} else {
pair.setPreviewUrl(media.getUrl());
pair.setVideoUrl(null);
}
return pair;
}
@Override
public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) {
PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId());
@@ -147,7 +236,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 +249,71 @@ public class PlayClerkDataReviewInfoServiceImpl
}
}
private void synchronizeApprovedAlbumMedia(PlayClerkDataReviewInfoEntity reviewInfo) {
PlayClerkUserInfoEntity clerkInfo = playClerkUserInfoService.getById(reviewInfo.getClerkId());
if (clerkInfo == null) {
throw new CustomException("店员信息不存在,无法同步媒资");
}
List<String> rawContent = reviewInfo.getDataContent();
List<String> sanitized = CollectionUtil.isEmpty(rawContent)
? Collections.emptyList()
: rawContent.stream().filter(StrUtil::isNotBlank).map(String::trim).distinct()
.collect(Collectors.toList());
List<String> 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.READY.getCode());
media.setUrl(url);
Map<String, Object> metadata = new HashMap<>();
metadata.put("legacySource", "album_review");
media.setMetadata(metadata);
mediaService.normalizeAndSave(media);
media.setStatus(MediaStatus.READY.getCode());
mediaService.updateById(media);
return media;
}
/**
* 修改店员资料审核
*
@@ -194,4 +349,28 @@ public class PlayClerkDataReviewInfoServiceImpl
public int deletePlayClerkDataReviewInfoById(String id) {
return playClerkDataReviewInfoMapper.deleteById(id);
}
/**
* 简单的预览地址 / 视频地址对,避免在主体逻辑中使用 Map 或魔法下标。
*/
private static class MediaPreviewPair {
private String previewUrl;
private String videoUrl;
String getPreviewUrl() {
return previewUrl;
}
void setPreviewUrl(String previewUrl) {
this.previewUrl = previewUrl;
}
String getVideoUrl() {
return videoUrl;
}
void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
}
}

View File

@@ -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<PlayClerkMediaAssetMapper, PlayClerkMediaAssetEntity>
implements IPlayClerkMediaAssetService {
@Resource
private IPlayMediaService mediaService;
@Override
@Transactional(rollbackFor = Exception.class)
public PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId,
ClerkMediaUsage usage) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> 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<String> mediaIds) {
List<String> ordered = distinctMediaIds(mediaIds);
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
Map<String, PlayClerkMediaAssetEntity> assetsByMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
List<PlayClerkMediaAssetEntity> 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<String> 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<String> mediaIds) {
List<String> ordered = distinctMediaIds(mediaIds);
if (CollectionUtil.isEmpty(ordered)) {
return;
}
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
Map<String, PlayClerkMediaAssetEntity> assetsByMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
List<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> listByState(String clerkId, ClerkMediaUsage usage,
Collection<ClerkMediaReviewState> states) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> listActiveByUsage(String clerkId, ClerkMediaUsage usage) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> 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<String> approvedValues,
String reviewRecordId, String note) {
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
List<String> normalized = distinctMediaIds(approvedValues);
Map<String, PlayClerkMediaAssetEntity> byMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
Map<String, PlayClerkMediaAssetEntity> byUrl = buildAssetByUrlMap(assets);
List<PlayClerkMediaAssetEntity> updates = new ArrayList<>();
Set<String> 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<PlayClerkMediaAssetEntity> 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<String> distinctMediaIds(List<String> mediaIds) {
if (CollectionUtil.isEmpty(mediaIds)) {
return Collections.emptyList();
}
return mediaIds.stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
}
private Map<String, PlayClerkMediaAssetEntity> buildAssetByUrlMap(List<PlayClerkMediaAssetEntity> assets) {
if (CollectionUtil.isEmpty(assets)) {
return Collections.emptyMap();
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).collect(Collectors.toList());
List<PlayMediaEntity> mediaList = mediaService.listByIds(mediaIds);
if (CollectionUtil.isEmpty(mediaList)) {
return Collections.emptyMap();
}
Map<String, String> mediaIdToUrl = mediaList.stream()
.filter(item -> StrUtil.isNotBlank(item.getUrl()))
.collect(Collectors.toMap(PlayMediaEntity::getId, PlayMediaEntity::getUrl, (left, right) -> left));
Map<String, PlayClerkMediaAssetEntity> map = new HashMap<>();
for (PlayClerkMediaAssetEntity asset : assets) {
String url = mediaIdToUrl.get(asset.getMediaId());
if (StrUtil.isNotBlank(url)) {
map.put(url, asset);
}
}
return map;
}
}

View File

@@ -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,7 +63,9 @@ 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;
import java.util.Map;
import java.util.Objects;
@@ -69,9 +81,7 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30
*/
@Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity>
implements
IPlayClerkUserInfoService {
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
@@ -87,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Resource
private IPlayCustomFollowInfoService customFollowInfoService;
@Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private IPlayOrderInfoService playOrderInfoService;
@@ -131,8 +145,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) {
@@ -157,8 +170,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 查询店员
*
* @param id
* 店员主键
* @param id 店员主键
* @return 店员
*/
@Override
@@ -173,13 +185,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService
.queryByClerkId(userInfo.getId(), "0");
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream()
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false);
}
@@ -217,18 +225,19 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
// 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(),
clerkCommodityEntity.getEnablingState()));
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
}
result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
result.setPcData(this.getPcData(userInfo));
result.setLevelInfo(playClerkLevelInfoService.selectPlayClerkLevelInfoById(userInfo.getLevelId()));
List<MediaVo> 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;
}
@@ -265,10 +274,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isBlank(clerkId)) {
return;
}
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class)
.eq(PlayClerkUserInfoEntity::getId, clerkId)
.set(PlayClerkUserInfoEntity::getToken, "empty")
.set(PlayClerkUserInfoEntity::getOnlineState, "0");
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper);
}
@@ -286,21 +292,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
@Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
// 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation,
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
}
/**
* 查询店员列表
*
* @param vo
* 店员查询对象
* @param vo 店员查询对象
* @return 店员
*/
@Override
@@ -311,12 +313,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity,
"address");
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
// 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
// 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) {
@@ -345,11 +345,33 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
// 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END")
lambdaQueryWrapper
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END")
.orderByAsc(PlayClerkLevelInfoEntity::getOrderNumber)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState);
.orderByAsc(PlayClerkUserInfoEntity::getCreatedTime)
.orderByAsc(PlayClerkUserInfoEntity::getNickname)
.orderByAsc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserInfoResultVo> pageResult = this.baseMapper.selectJoinPage(page,
PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
if (pageResult != null && pageResult.getRecords() != null) {
List<PlayClerkUserInfoResultVo> deduped = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (PlayClerkUserInfoResultVo record : pageResult.getRecords()) {
String id = record.getId();
if (id == null || !seen.add(id)) {
continue;
}
deduped.add(record);
}
pageResult.setRecords(deduped);
}
if (pageResult != null) {
attachMediaToResultVos(pageResult.getRecords(), false);
}
return pageResult;
}
@Override
@@ -364,8 +386,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname")
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
@@ -377,14 +398,11 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
}
// 查询店员订单信息
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class,
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
}
@Override
@@ -483,12 +501,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -508,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
record.setOrderContinueNumber(String.valueOf(orderContinueNumber));
}
attachMediaToAdminVos(page.getRecords());
return page;
}
@@ -519,10 +535,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService
.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
}
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -534,11 +548,37 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
return voPage;
}
@Override
public PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId) {
PlayClerkUserInfoEntity entity = this.baseMapper.selectById(clerkId);
if (entity == null) {
throw new CustomException("店员不存在");
}
PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class);
vo.setAddress(entity.getCity());
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
String followState = "0";
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId)
.eq(PlayCustomFollowInfoEntity::getClerkId, clerkId);
PlayCustomFollowInfoEntity followInfo = customFollowInfoService.getOne(wrapper, false);
if (followInfo != null && "1".equals(followInfo.getFollowState())) {
followState = "1";
}
}
vo.setFollowState(followState);
List<MediaVo> mediaList = loadMediaForClerk(clerkId, false);
vo.setMediaList(mergeLegacyAlbum(entity.getAlbum(), mediaList));
return vo;
}
/**
* 新增店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
@@ -552,16 +592,12 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 修改店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId())
&& (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getListingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
@@ -576,8 +612,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 批量删除店员
*
* @param ids
* 需要删除的店员主键
* @param ids 需要删除的店员主键
* @return 结果
*/
@Override
@@ -588,8 +623,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 删除店员信息
*
* @param id
* 店员主键
* @param id 店员主键
* @return 结果
*/
@Override
@@ -603,13 +637,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname,
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper);
}
@Override
public List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant() {
return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant();
}
@Override
public JSONObject getPcData(PlayClerkUserInfoEntity entity) {
JSONObject data = new JSONObject();
@@ -621,8 +658,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator");
return data;
@@ -632,8 +668,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader");
return data;
}
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter");
return data;
@@ -645,13 +680,101 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (beforeUpdate == null) {
return;
}
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(),
beforeUpdate.getOnboardingState())
|| ListingStatus.transitionedToDelisted(updatedPayload.getListingState(),
beforeUpdate.getListingState())
|| ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(),
beforeUpdate.getClerkState())) {
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId());
}
}
private void attachMediaToResultVos(List<PlayClerkUserInfoResultVo> records, boolean includePending) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserInfoResultVo::getId).collect(Collectors.toList()), includePending);
for (PlayClerkUserInfoResultVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private void attachMediaToAdminVos(List<PlayClerkUserReturnVo> records) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserReturnVo::getId).collect(Collectors.toList()), true);
for (PlayClerkUserReturnVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private List<MediaVo> loadMediaForClerk(String clerkId, boolean includePending) {
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(Collections.singletonList(clerkId), includePending);
return new ArrayList<>(mediaMap.getOrDefault(clerkId, Collections.emptyList()));
}
private Map<String, List<MediaVo>> resolveMediaByAssets(List<String> clerkIds, boolean includePending) {
if (CollectionUtil.isEmpty(clerkIds)) {
return Collections.emptyMap();
}
List<ClerkMediaReviewState> targetStates = includePending
? Arrays.asList(ClerkMediaReviewState.APPROVED, ClerkMediaReviewState.PENDING,
ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.REJECTED)
: Collections.singletonList(ClerkMediaReviewState.APPROVED);
List<PlayClerkMediaAssetEntity> 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<String, List<MediaVo>> empty = new HashMap<>();
clerkIds.forEach(id -> empty.put(id, Collections.emptyList()));
return empty;
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct()
.collect(Collectors.toList());
Map<String, PlayMediaEntity> mediaById = CollectionUtil.isEmpty(mediaIds)
? Collections.emptyMap()
: mediaService.listByIds(mediaIds).stream()
.collect(Collectors.toMap(PlayMediaEntity::getId, item -> item, (left, right) -> left));
Map<String, List<PlayClerkMediaAssetEntity>> groupedAssets = assets.stream()
.collect(Collectors.groupingBy(PlayClerkMediaAssetEntity::getClerkId));
Map<String, List<MediaVo>> 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<MediaVo> mergeLegacyAlbum(List<String> legacyAlbum, List<MediaVo> destination) {
if (CollectionUtil.isEmpty(legacyAlbum)) {
return destination;
}
Set<String> 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;
}
}

View File

@@ -0,0 +1,132 @@
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.List;
import java.util.Map;
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<PlayClerkUserInfoEntity> 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<String> album = clerk.getAlbum();
if (CollectionUtil.isEmpty(album)) {
continue;
}
List<String> sanitizedAlbum = album.stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
if (CollectionUtil.isEmpty(sanitizedAlbum)) {
continue;
}
List<String> approvedMediaIds = new ArrayList<>();
for (String value : sanitizedAlbum) {
PlayMediaEntity media = resolveMediaEntity(clerk, value);
if (media == null) {
continue;
}
media.setStatus(MediaStatus.READY.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);
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.READY.getCode());
media.setUrl(value);
Map<String, Object> metadata = new HashMap<>();
metadata.put("legacySource", "album_migration");
media.setMetadata(metadata);
mediaService.normalizeAndSave(media);
return media;
}
}

View File

@@ -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
*
* <p>存储各类业务(店员、顾客等)的图片/视频。</p>
*/
@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<String, Object> metadata;
private Date createdTime;
private Date updatedTime;
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.media.enums;
public final class MediaOwnerType {
private MediaOwnerType() {
}
public static final String CLERK = "clerk";
}

View File

@@ -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);
}
}

View File

@@ -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<PlayMediaEntity> {
}

View File

@@ -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<PlayMediaEntity> {
List<PlayMediaEntity> listByOwner(String ownerType, String ownerId);
List<PlayMediaEntity> listByOwner(String ownerType, String ownerId, Collection<String> statuses);
List<PlayMediaEntity> listApprovedByOwner(String ownerType, String ownerId);
PlayMediaEntity normalizeAndSave(PlayMediaEntity entity);
void updateOrder(String ownerType, String ownerId, List<String> orderedIds);
void softDelete(String ownerType, String ownerId, String mediaId);
}

View File

@@ -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<PlayMediaMapper, PlayMediaEntity>
implements IPlayMediaService {
@Override
public List<PlayMediaEntity> listByOwner(String ownerType, String ownerId) {
return listByOwner(ownerType, ownerId, null);
}
@Override
public List<PlayMediaEntity> listByOwner(String ownerType, String ownerId, Collection<String> statuses) {
LambdaQueryWrapper<PlayMediaEntity> 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<PlayMediaEntity> listApprovedByOwner(String ownerType, String ownerId) {
return listByOwner(ownerType, ownerId, Collections.singleton(MediaStatus.READY.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<String> orderedIds) {
List<PlayMediaEntity> mediaList = listByOwner(ownerType, ownerId);
if (CollectionUtil.isEmpty(mediaList)) {
return;
}
Map<String, PlayMediaEntity> mediaById = mediaList.stream()
.collect(Collectors.toMap(PlayMediaEntity::getId, item -> item));
Set<String> keepSet = new LinkedHashSet<>();
if (CollectionUtil.isNotEmpty(orderedIds)) {
keepSet.addAll(orderedIds);
}
List<PlayMediaEntity> 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<PlayMediaEntity> 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;
}
}

View File

@@ -15,6 +15,7 @@ import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder;
@@ -28,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -59,6 +61,9 @@ public class PlayOrderInfoController {
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/**
* 分页查询订单列表
*/
@@ -115,11 +120,8 @@ public class PlayOrderInfoController {
context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason());
OrderRevocationContext.EarningsAdjustStrategy strategy = vo.getEarningsStrategy() != null
? vo.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
context.setEarningsStrategy(strategy);
context.setCounterClerkId(vo.getCounterClerkId());
context.setDeductClerkEarnings(vo.isDeductClerkEarnings());
context.setEarningsAdjustAmount(vo.getDeductAmount());
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
@@ -127,6 +129,29 @@ public class PlayOrderInfoController {
return R.ok("撤销成功");
}
@ApiOperation(value = "撤销限额", notes = "查询指定订单可退金额与可扣回收益")
@GetMapping("/{id}/revocationLimits")
public R getRevocationLimits(@PathVariable("id") String id) {
PlayOrderInfoEntity order = orderInfoService.selectOrderInfoById(id);
BigDecimal maxRefundAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal maxDeductAmount = BigDecimal.ZERO;
if (order.getAcceptBy() != null) {
maxDeductAmount = Optional.ofNullable(earningsService.getRemainingEarningsForOrder(order.getId(), order.getAcceptBy()))
.orElse(BigDecimal.ZERO);
}
if (maxDeductAmount.compareTo(BigDecimal.ZERO) < 0) {
maxDeductAmount = BigDecimal.ZERO;
}
PlayOrderRevocationLimitsVo limitsVo = new PlayOrderRevocationLimitsVo();
limitsVo.setOrderId(order.getId());
limitsVo.setMaxRefundAmount(maxRefundAmount);
limitsVo.setMaxDeductAmount(maxDeductAmount);
limitsVo.setDefaultDeductAmount(maxDeductAmount);
limitsVo.setDeductible(order.getAcceptBy() != null);
return R.ok(limitsVo);
}
/**
* 管理后台强制取消进行中订单
*/

View File

@@ -26,30 +26,29 @@ public class OrderRevocationEarningsListener {
return;
}
OrderRevocationContext context = event.getContext();
switch (context.getEarningsStrategy()) {
case NONE:
if (!context.isDeductClerkEarnings()) {
return;
case REVERSE_CLERK:
earningsService.reverseByOrder(event.getOrderSnapshot().getId(), context.getOperatorId());
return;
case COUNTER_TO_PEIPEI:
createCounterLine(event);
return;
default:
throw new CustomException("未知的收益处理策略");
}
createCounterLine(event);
}
private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext();
if (context == null) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = context.getCounterClerkId();
if (targetClerkId == null) {
String targetClerkId = order.getAcceptBy();
if (targetClerkId == null || targetClerkId.trim().isEmpty()) {
throw new CustomException("需要指定收益冲销目标账号");
}
BigDecimal amount = context.getRefundAmount();
BigDecimal amount = context.getEarningsAdjustAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
}
earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId());
}

View File

@@ -26,31 +26,15 @@ public class OrderRevocationContext {
private boolean refundToCustomer;
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE;
@Nullable
private String counterClerkId;
private boolean deductClerkEarnings;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
@Nullable
private BigDecimal earningsAdjustAmount;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource;
return this;
}
public enum EarningsAdjustStrategy {
NONE("NO_ADJUST"),
REVERSE_CLERK("REV_CLERK"),
COUNTER_TO_PEIPEI("CTR_PEIPEI");
private final String logCode;
EarningsAdjustStrategy(String logCode) {
this.logCode = logCode;
}
public String getLogCode() {
return logCode;
}
}
}

View File

@@ -0,0 +1,26 @@
package com.starry.admin.modules.order.module.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import lombok.Data;
@Data
@ApiModel(value = "撤销限额信息", description = "展示撤销时可退金额、可扣回收益等信息")
public class PlayOrderRevocationLimitsVo {
@ApiModelProperty("订单ID")
private String orderId;
@ApiModelProperty("最大可退金额")
private BigDecimal maxRefundAmount = BigDecimal.ZERO;
@ApiModelProperty("最大可扣回收益")
private BigDecimal maxDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("建议扣回金额")
private BigDecimal defaultDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("是否存在可扣回店员")
private boolean deductible;
}

View File

@@ -1,6 +1,5 @@
package com.starry.admin.modules.order.module.vo;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
@@ -24,9 +23,9 @@ public class PlayOrderRevocationVo {
@ApiModelProperty(value = "撤销原因")
private String refundReason;
@ApiModelProperty(value = "收益处理策略NONE/REVERSE_CLERK/COUNTER_TO_PEIPEI")
private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE;
@ApiModelProperty(value = "是否扣回店员收益")
private boolean deductClerkEarnings;
@ApiModelProperty(value = "收益冲销目标账号ID策略为 COUNTER_TO_PEIPEI 时必填")
private String counterClerkId;
@ApiModelProperty(value = "扣回金额,未填写则默认按本单收益全额扣回")
private BigDecimal deductAmount;
}

View File

@@ -632,18 +632,32 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("仅支持撤销普通服务订单");
}
OrderRevocationContext.EarningsAdjustStrategy strategy = context.getEarningsStrategy() != null
? context.getEarningsStrategy()
: OrderRevocationContext.EarningsAdjustStrategy.NONE;
if (strategy == OrderRevocationContext.EarningsAdjustStrategy.REVERSE_CLERK
&& earningsService.hasLockedLines(order.getId())) {
throw new CustomException("收益已提现或处理中,无法撤销");
}
String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode();
context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
context.setOperatorId(operatorId);
if (context.isDeductClerkEarnings()) {
String targetClerkId = order.getAcceptBy();
if (StrUtil.isBlank(targetClerkId)) {
throw new CustomException("未找到可冲销的店员收益账号");
}
BigDecimal availableEarnings = Optional.ofNullable(
earningsService.getRemainingEarningsForOrder(order.getId(), targetClerkId))
.orElse(BigDecimal.ZERO);
if (availableEarnings.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("本单店员收益已全部扣回");
}
BigDecimal requested = context.getEarningsAdjustAmount();
if (requested == null || requested.compareTo(BigDecimal.ZERO) <= 0) {
requested = availableEarnings;
}
if (requested.compareTo(availableEarnings) > 0) {
throw new CustomException("扣回金额不能超过本单收益" + availableEarnings);
}
context.setEarningsAdjustAmount(requested);
} else {
context.setEarningsAdjustAmount(BigDecimal.ZERO);
}
BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal refundAmount = context.getRefundAmount();
@@ -708,9 +722,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
strategy != null
? strategy.getLogCode()
: OrderRevocationContext.EarningsAdjustStrategy.NONE.getLogCode());
context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);

View File

@@ -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<MediaVo> toVoList(List<PlayClerkMediaAssetEntity> assets,
Map<String, PlayMediaEntity> 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());
}
}

View File

@@ -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<MediaVo> toVoList(List<PlayMediaEntity> entities) {
if (entities == null) {
return Collections.emptyList();
}
return entities.stream()
.filter(Objects::nonNull)
.map(MediaAssembler::toVo)
.collect(Collectors.toList());
}
}

View File

@@ -6,12 +6,16 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
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.*;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.clerk.service.*;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserReviewInfoServiceImpl;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.PlayOrderCompleteVo;
@@ -41,6 +45,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;
@@ -53,6 +58,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -120,6 +126,10 @@ public class WxClerkController {
private SmsUtils smsUtils;
@Resource
private WxCustomMpService wxCustomMpService;
@Resource
private com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
/**
* 店员获取个人业绩信息
@@ -268,7 +278,7 @@ public class WxClerkController {
entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(vo.getNickname()));
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ApiOperation(value = "更新性别", notes = "店员更新性别")
@@ -283,7 +293,7 @@ public class WxClerkController {
entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex())));
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ApiOperation(value = "更新头像", notes = "店员更新头像")
@@ -305,25 +315,138 @@ public class WxClerkController {
list.add(vo.getAvatar());
entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ClerkUserLogin
@PostMapping("/user/updateAlbum")
public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) {
PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo();
// PlayClerkDataReviewInfoEntity entity =
// playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "2", "0");
// if (entity != null) {
// throw new CustomException("已有申请未审核");
// }
List<String> requested = vo.getAlbum() == null ? new ArrayList<>() : vo.getAlbum().stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
// 查询当前所有已审核通过的 PROFILE 媒资
List<PlayClerkMediaAssetEntity> approvedAssets = clerkMediaAssetService.listByState(
userInfo.getId(),
ClerkMediaUsage.PROFILE,
Collections.singletonList(ClerkMediaReviewState.APPROVED));
LinkedHashSet<String> requestedSet = new LinkedHashSet<>(requested);
if (requestedSet.isEmpty()) {
throw new CustomException("最少上传一张照片");
}
// 计算哪些是新媒资(需走审核),哪些是纯删除/排序
java.util.Set<String> approvedIds = approvedAssets.stream()
.map(PlayClerkMediaAssetEntity::getMediaId)
.filter(StrUtil::isNotBlank)
.collect(java.util.stream.Collectors.toSet());
java.util.Set<String> newMediaIds = requestedSet.stream()
.filter(id -> !approvedIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
if (log.isInfoEnabled()) {
log.info("[ClerkAlbumUpdate] clerkId={} tenantId={} requestedSet={} approvedIds={} newMediaIds={}",
userInfo.getId(), userInfo.getTenantId(), requestedSet, approvedIds, newMediaIds);
}
if (!newMediaIds.isEmpty()) {
// 新增媒资必须是当前店员本人名下、已就绪的媒资,才能进入审核流程
java.util.List<com.starry.admin.modules.media.entity.PlayMediaEntity> newMediaEntities =
mediaService.lambdaQuery()
.in(com.starry.admin.modules.media.entity.PlayMediaEntity::getId, newMediaIds)
.list();
java.util.Set<String> existingMediaIds = newMediaEntities.stream()
.map(com.starry.admin.modules.media.entity.PlayMediaEntity::getId)
.collect(java.util.stream.Collectors.toSet());
java.util.Set<String> missingMediaIds = new java.util.HashSet<>(newMediaIds);
missingMediaIds.removeAll(existingMediaIds);
if (!missingMediaIds.isEmpty()) {
// 这里很可能是历史相册里的纯 URL未经过媒资化我们记录日志但不直接失败
// 在审核内容中仍然保留这些字符串,由审核端用回显逻辑处理。
log.warn(
"[ClerkAlbumUpdate] some album entries not found in play_media, treating as legacy values, clerkId={} tenantId={} missingIds={} existingIds={}",
userInfo.getId(),
userInfo.getTenantId(),
missingMediaIds,
existingMediaIds);
}
if (log.isInfoEnabled()) {
log.info(
"[ClerkAlbumUpdate] loaded newMediaEntities for validation, clerkId={} tenantId={} mediaSummaries={}",
userInfo.getId(),
userInfo.getTenantId(),
newMediaEntities.stream()
.map(m -> String.format("id=%s,status=%s,ownerType=%s,ownerId=%s,tenantId=%s",
m.getId(), m.getStatus(), m.getOwnerType(), m.getOwnerId(), m.getTenantId()))
.collect(java.util.stream.Collectors.toList()));
}
for (com.starry.admin.modules.media.entity.PlayMediaEntity media : newMediaEntities) {
boolean tenantMatched = userInfo.getTenantId().equals(media.getTenantId());
boolean ownerTypeMatched = MediaOwnerType.CLERK.equals(media.getOwnerType());
boolean ownerIdMatched = userInfo.getId().equals(media.getOwnerId());
boolean statusReady = com.starry.admin.modules.media.enums.MediaStatus.READY.getCode()
.equals(media.getStatus());
if (!tenantMatched || !ownerTypeMatched || !ownerIdMatched || !statusReady) {
log.warn(
"[ClerkAlbumUpdate] invalid new media for clerk, clerkId={} tenantId={} mediaId={} mediaStatus={} mediaTenantId={} mediaOwnerType={} mediaOwnerId={} tenantMatched={} ownerTypeMatched={} ownerIdMatched={} statusReady={}",
userInfo.getId(),
userInfo.getTenantId(),
media.getId(),
media.getStatus(),
media.getTenantId(),
media.getOwnerType(),
media.getOwnerId(),
tenantMatched,
ownerTypeMatched,
ownerIdMatched,
statusReady);
throw new CustomException("存在无效的照片/视频,请刷新后重试");
}
if (!statusReady) {
log.warn(
"[ClerkAlbumUpdate] media not in READY state for clerk, clerkId={} tenantId={} mediaId={} mediaStatus={}",
userInfo.getId(),
userInfo.getTenantId(),
media.getId(),
media.getStatus());
throw new CustomException("存在未完成上传的照片/视频,请稍后重试");
}
}
// 只要存在新增媒资,则按原有逻辑走资料审核,由审核通过时统一生效
PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();
entity.setClerkId(userInfo.getId());
entity.setDataType("2");
entity.setReviewState("0");
entity.setDataContent(vo.getAlbum());
entity.setDataContent(new ArrayList<>(requestedSet));
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
// 仅删除/排序:直接应用变更,不再生成审核记录
// 先根据新的顺序更新 orderIndex
clerkMediaAssetService.reorder(userInfo.getId(), ClerkMediaUsage.PROFILE, new ArrayList<>(requestedSet));
// 再对不再保留的媒资执行软删除
java.util.Set<String> requestedOnly = new java.util.HashSet<>(requestedSet);
java.util.Set<String> deletedMediaIds = approvedIds.stream()
.filter(id -> !requestedOnly.contains(id))
.collect(java.util.stream.Collectors.toSet());
for (String mediaId : deletedMediaIds) {
clerkMediaAssetService.softDelete(userInfo.getId(), mediaId);
mediaService.softDelete(MediaOwnerType.CLERK, userInfo.getId(), mediaId);
}
return R.ok().message("修改成功");
}
@ClerkUserLogin
@@ -343,7 +466,7 @@ public class WxClerkController {
list.add(vo.getAudio());
entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ClerkUserLogin
@@ -394,10 +517,10 @@ public class WxClerkController {
* @return 店员列表
*/
@PostMapping("/user/queryByPage")
public R queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
public TypedR<IPage<PlayClerkUserInfoResultVo>> queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo,
customUserService.getLoginUserId());
return R.ok(page);
return TypedR.ok(page);
}
/**

View File

@@ -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<String> 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<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage,
Arrays.asList(ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.PENDING,
ClerkMediaReviewState.REJECTED));
Map<String, PlayMediaEntity> 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<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage,
Collections.singletonList(ClerkMediaReviewState.APPROVED));
Map<String, PlayMediaEntity> 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<String, PlayMediaEntity> loadMediaMap(List<PlayClerkMediaAssetEntity> assets) {
if (CollUtil.isEmpty(assets)) {
return Collections.emptyMap();
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct()
.collect(Collectors.toList());
List<PlayMediaEntity> mediaList = mediaService.listByIds(mediaIds);
if (CollUtil.isEmpty(mediaList)) {
return Collections.emptyMap();
}
return mediaList.stream().collect(Collectors.toMap(PlayMediaEntity::getId, item -> item));
}
}

View File

@@ -151,16 +151,9 @@ public class WxCustomController {
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PlayClerkUserInfoResultVo.class)})
@GetMapping("/queryClerkDetailedById")
public R queryClerkDetailedById(@RequestParam("id") String id) {
PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(id);
PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class);
vo.setAddress(entity.getCity());
// 查询是否关注,未登录情况下,默认为未关注
String loginUserId = customUserService.getLoginUserId();
if (StringUtils.isNotEmpty(loginUserId)) {
vo.setFollowState(playCustomFollowInfoService.queryFollowState(loginUserId, vo.getId()));
}
// 服务项目
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
PlayClerkUserInfoResultVo vo = clerkUserInfoService.buildCustomerDetail(id,
StringUtils.isNotEmpty(loginUserId) ? loginUserId : "");
return R.ok(vo);
}

View File

@@ -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<String> album = new ArrayList<>();
/**
* 新媒资列表
*/
private List<MediaVo> mediaList = new ArrayList<>();
/**
* 相册是否运行编辑
*/

View File

@@ -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<String> mediaIds;
}

View File

@@ -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<String, Object> metadata;
private String usage;
private String reviewState;
private LocalDateTime submittedTime;
private String reviewNote;
}

View File

@@ -75,6 +75,12 @@ public class PlayClerkUserInfoResultVo {
@ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/**
* 个性签名
*/

View File

@@ -0,0 +1,284 @@
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 = 45_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);
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.normalizeAndSave(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<String, Object> 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("视频时长不能超过45秒");
}
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();
}
}

View File

@@ -8,7 +8,8 @@ import com.fasterxml.jackson.annotation.JsonValue;
*/
public enum EarningsType {
ORDER("ORDER"),
COMMISSION("COMMISSION");
COMMISSION("COMMISSION"),
ADJUSTMENT("ADJUSTMENT");
@EnumValue
@JsonValue

View File

@@ -18,9 +18,7 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
void reverseByOrder(String orderId, String operatorId);
void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
boolean hasLockedLines(String orderId);
BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId);
}

View File

@@ -2,7 +2,6 @@ package com.starry.admin.modules.withdraw.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -15,7 +14,6 @@ import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -98,17 +96,6 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
return picked;
}
@Override
public void reverseByOrder(String orderId, String operatorId) {
if (StrUtil.isBlank(orderId)) {
return;
}
this.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("available", "frozen"))
.set(EarningsLineEntity::getStatus, "reversed"));
}
@Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
@@ -119,27 +106,63 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
return;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime resolvedUnlock = now;
String resolvedStatus = "available";
List<EarningsLineEntity> references = this.baseMapper.selectList(new LambdaQueryWrapper<EarningsLineEntity>()
.eq(EarningsLineEntity::getOrderId, orderId)
.eq(EarningsLineEntity::getClerkId, targetClerkId)
.eq(EarningsLineEntity::getDeleted, false)
.orderByAsc(EarningsLineEntity::getUnlockTime));
EarningsLineEntity reference = references.stream()
.filter(line -> line.getAmount() != null && line.getAmount().compareTo(BigDecimal.ZERO) > 0)
.findFirst()
.orElse(null);
if (reference == null) {
throw new IllegalStateException("未找到可冲销的收益记录");
}
LocalDateTime refUnlock = reference.getUnlockTime();
String refStatus = reference.getStatus();
boolean shouldPreserveFreeze = "frozen".equalsIgnoreCase(refStatus)
&& refUnlock != null
&& refUnlock.isAfter(now);
if (shouldPreserveFreeze) {
resolvedUnlock = refUnlock;
resolvedStatus = "frozen";
} else {
resolvedUnlock = now;
resolvedStatus = "available";
}
EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid());
line.setOrderId(orderId);
line.setTenantId(tenantId);
line.setClerkId(targetClerkId);
line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ORDER);
line.setStatus("available");
line.setUnlockTime(LocalDateTime.now());
line.setEarningType(EarningsType.ADJUSTMENT);
line.setStatus(resolvedStatus);
line.setUnlockTime(resolvedUnlock);
this.save(line);
}
@Override
public boolean hasLockedLines(String orderId) {
if (StrUtil.isBlank(orderId)) {
return false;
public BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId) {
if (StrUtil.hasBlank(orderId, clerkId)) {
return BigDecimal.ZERO;
}
Long count = this.lambdaQuery()
List<EarningsLineEntity> lines = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId)
.in(EarningsLineEntity::getStatus, Arrays.asList("withdrawing", "withdrawn"))
.count();
return count != null && count > 0;
.eq(EarningsLineEntity::getClerkId, clerkId)
.eq(EarningsLineEntity::getDeleted, false)
.list();
BigDecimal total = BigDecimal.ZERO;
for (EarningsLineEntity line : lines) {
BigDecimal amount = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
total = total.add(amount);
}
return total;
}
}

View File

@@ -14,8 +14,8 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true&connectionCollation=utf8mb4_general_ci&sessionVariables=collation_connection=utf8mb4_general_ci
username: apitest
password: apitest
username: root
password: root
druid:
enable: true
db-type: mysql

View File

@@ -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}

View File

@@ -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;

View File

@@ -17,8 +17,12 @@ import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -37,6 +41,13 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
private final List<String> levelIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private int scenarioSequence = 0;
private static final Comparator<ClerkScenario> BACKEND_ORDERING = Comparator
.comparing(ClerkScenario::isOnline, Comparator.reverseOrder())
.thenComparing(ClerkScenario::isPinned, Comparator.reverseOrder())
.thenComparingLong(ClerkScenario::getLevelOrder)
.thenComparingInt(ClerkScenario::getSequence)
.thenComparing(ClerkScenario::getId);
@AfterEach
void tearDown() {
@@ -83,9 +94,10 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
}
assertThat(orderedIds).contains(lowOrderClerkId, highOrderClerkId);
assertThat(orderedIds.indexOf(lowOrderClerkId))
.withFailMessage("Unexpected order for token %s: %s", filterToken, orderedIds)
.isLessThan(orderedIds.indexOf(highOrderClerkId));
assertThat(orderedIds.indexOf(highOrderClerkId))
.withFailMessage("Online clerk should appear before offline regardless of level. token=%s list=%s",
filterToken, orderedIds)
.isLessThan(orderedIds.indexOf(lowOrderClerkId));
}
@Test
@@ -319,4 +331,178 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
@Test
void listOrderingStableWithMultipleCriteria() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("stable", 10L, 80);
String filterToken = "stable-" + IdUtils.getUuid().substring(0, 6);
String pinnedOnline = createClerk(filterToken + "-pinned-online", level.getId(), "1");
togglePin(pinnedOnline, "1");
String pinnedOffline = createClerk(filterToken + "-pinned-offline", level.getId(), "0");
togglePin(pinnedOffline, "1");
String online1 = createClerk(filterToken + "-online-one", level.getId(), "1");
pause(50);
String online2 = createClerk(filterToken + "-online-two", level.getId(), "1");
String offline = createClerk(filterToken + "-offline", level.getId(), "0");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode records = root.path("data");
assertThat(records.isArray()).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
Map<String, ClerkScenario> expectedScenarios = new HashMap<>();
expectedScenarios.put(pinnedOnline, new ClerkScenario(pinnedOnline, 1L, true, true, 0));
expectedScenarios.put(online1, new ClerkScenario(online1, 1L, true, false, 1));
expectedScenarios.put(online2, new ClerkScenario(online2, 1L, true, false, 2));
expectedScenarios.put(pinnedOffline, new ClerkScenario(pinnedOffline, 1L, false, true, 3));
expectedScenarios.put(offline, new ClerkScenario(offline, 1L, false, false, 4));
List<ClerkScenario> actualScenarios = orderedIds.stream()
.map(expectedScenarios::get)
.collect(Collectors.toList());
for (int i = 1; i < actualScenarios.size(); i++) {
ClerkScenario previous = actualScenarios.get(i - 1);
ClerkScenario current = actualScenarios.get(i);
assertThat(previous).isNotNull();
assertThat(current).isNotNull();
assertThat(BACKEND_ORDERING.compare(previous, current))
.withFailMessage("Ordering violation between %s and %s, list=%s", previous.getId(), current.getId(),
orderedIds)
.isLessThanOrEqualTo(0);
}
}
@Test
void listOrderingHandlesBulkDataset() throws Exception {
ensureTenantContext();
String token = "bulk-" + IdUtils.getUuid().substring(0, 6);
PlayClerkLevelInfoEntity gold = createClerkLevel(token + "-gold", 1L, 90);
PlayClerkLevelInfoEntity silver = createClerkLevel(token + "-silver", 2L, 80);
PlayClerkLevelInfoEntity iron = createClerkLevel(token + "-iron", 3L, 70);
List<ClerkScenario> scenarios = new ArrayList<>();
scenarios.add(buildScenario(token, "G-Pin-ON", gold, true, true));
scenarios.add(buildScenario(token, "G-UnPin-ON", gold, true, false));
scenarios.add(buildScenario(token, "G-Pin-Off", gold, false, true));
scenarios.add(buildScenario(token, "G-UnPin-Off", gold, false, false));
scenarios.add(buildScenario(token, "S-Pin-ON", silver, true, true));
scenarios.add(buildScenario(token, "S-UnPin-ON", silver, true, false));
scenarios.add(buildScenario(token, "S-Pin-Off", silver, false, true));
scenarios.add(buildScenario(token, "S-UnPin-Off", silver, false, false));
scenarios.add(buildScenario(token, "I-Pin-ON", iron, true, true));
scenarios.add(buildScenario(token, "I-UnPin-ON", iron, true, false));
scenarios.add(buildScenario(token, "I-Pin-Off", iron, false, true));
scenarios.add(buildScenario(token, "I-UnPin-Off", iron, false, false));
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "80")
.param("nickname", token)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode records = root.path("data");
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
Map<String, ClerkScenario> scenarioById = scenarios.stream()
.collect(Collectors.toMap(ClerkScenario::getId, scenario -> scenario));
assertThat(orderedIds).containsExactlyInAnyOrderElementsOf(scenarioById.keySet());
List<ClerkScenario> orderedScenarios = orderedIds.stream()
.map(scenarioById::get)
.collect(Collectors.toList());
for (int i = 1; i < orderedScenarios.size(); i++) {
ClerkScenario previous = orderedScenarios.get(i - 1);
ClerkScenario current = orderedScenarios.get(i);
assertThat(BACKEND_ORDERING.compare(previous, current))
.withFailMessage("Ordering violation between %s and %s, list=%s",
previous.getId(), current.getId(), orderedIds)
.isLessThanOrEqualTo(0);
}
}
private ClerkScenario buildScenario(String token, String suffix, PlayClerkLevelInfoEntity level, boolean online, boolean pinned) {
String id = createClerk(token + "-" + suffix, level.getId(), online ? "1" : "0");
if (pinned) {
togglePin(id, "1");
}
long levelOrder = level.getOrderNumber() == null ? Long.MAX_VALUE : level.getOrderNumber();
ClerkScenario scenario = new ClerkScenario(id, levelOrder, online, pinned, scenarioSequence++);
pause(15);
return scenario;
}
private void togglePin(String clerkId, String pinState) {
ensureTenantContext();
PlayClerkUserInfoEntity update = new PlayClerkUserInfoEntity();
update.setId(clerkId);
update.setPinToTopState(pinState);
clerkUserInfoService.update(update);
}
private void pause(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private static class ClerkScenario {
private final String id;
private final long levelOrder;
private final boolean online;
private final boolean pinned;
private final int sequence;
ClerkScenario(String id, long levelOrder, boolean online, boolean pinned, int sequence) {
this.id = id;
this.levelOrder = levelOrder;
this.online = online;
this.pinned = pinned;
this.sequence = sequence;
}
String getId() {
return id;
}
long getLevelOrder() {
return levelOrder;
}
boolean isOnline() {
return online;
}
boolean isPinned() {
return pinned;
}
int getSequence() {
return sequence;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -210,12 +211,12 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
ensureTenantContext();
String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
LocalDateTime reference = LocalDateTime.now().plusHours(2);
PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.minusMinutes(10), order -> {
PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
@@ -225,7 +226,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
ObjectNode clerkKeywordPayload = baseQuery();
clerkKeywordPayload.put("keyword", "小测官");
clerkKeywordPayload.set("purchaserTime", range(reference.minusMinutes(5), reference.plusMinutes(5)));
clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15)));
clerkKeywordPayload.put("placeType", "1");
RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
JsonNode clerkRecords = clerkResponse.records;
@@ -239,7 +240,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
void listByPage_keywordRespectsAdditionalFilters() throws Exception {
ensureTenantContext();
String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().minusHours(3);
LocalDateTime reference = LocalDateTime.now().plusHours(3);
PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> {
order.setOrderStatus("3");
@@ -270,119 +271,183 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn");
seedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-保留收益");
payload.put("earningsStrategy", "NONE");
payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
.andReturn();
JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
}
@Test
void revokeCompletedOrder_reverseClerkBlockedWhenLocked() throws Exception {
void revokeCompletedOrder_reverseClerkCreatesNegativeLineEvenWhenLocked() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("210.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("20.00"));
payload.put("refundReason", "API撤销-冲销收益");
payload.put("earningsStrategy", "REVERSE_CLERK");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("20.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("收益已提现或处理中,无法撤销"));
}
.andReturn();
JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
@Test
void revokeCompletedOrder_counterStrategyCreatesNegativeLineAfterWithdrawal() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(90);
PlayOrderInfoEntity order = persistOrder("RVK", "counter", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("230.00"));
entity.setEstimatedRevenue(new BigDecimal("150.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("140.00"), "withdrawn");
String counterClerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID + "-pei-hold";
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("50.00"));
payload.put("refundReason", "API撤销-转待扣");
payload.put("earningsStrategy", "COUNTER_TO_PEIPEI");
payload.put("counterClerkId", counterClerkId);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
assertThat(lines).hasSize(2);
EarningsLineEntity counterLine = lines.stream()
EarningsLineEntity negativeLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-50.00"));
assertThat(counterLine.getStatus()).isEqualTo("available");
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId);
earningsLineIdsToCleanup.add(counterLine.getId());
assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_refundAndCounterCreatesRecords() throws Exception {
void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(45);
PlayOrderInfoEntity order = persistOrder("RVK", "refundCounter", reference, entity -> {
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("320.00"));
entity.setEstimatedRevenue(new BigDecimal("180.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
seedLockedEarningLine(order.getId(), new BigDecimal("110.00"), "withdrawn");
String counterClerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID + "-refund-counter";
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
LocalDateTime unlockAt = LocalDateTime.now().plusHours(3).withNano(0);
seedEarningLine(order.getId(), new BigDecimal("90.00"), "frozen", unlockAt);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", true);
payload.put("refundAmount", new BigDecimal("60.00"));
payload.put("refundReason", "API撤销-退款并待扣");
payload.put("earningsStrategy", "COUNTER_TO_PEIPEI");
payload.put("counterClerkId", counterClerkId);
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-冻结扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("30.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("frozen");
assertThat(negativeLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_deductMakesWithdrawnLineAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "withdrawn", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("0.00"));
payload.put("refundReason", "API撤销-提现扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("40.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("available");
assertThat(negativeLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_defaultsDeductAmountWhenMissing() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(50);
PlayOrderInfoEntity order = persistOrder("RVK", "autoDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("260.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("75.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-自动扣回");
payload.put("deductClerkEarnings", true);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
@@ -390,12 +455,55 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-75.00"));
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_refundAndDeductCreatesRecords() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(35);
PlayOrderInfoEntity order = persistOrder("RVK", "refundDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("300.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", true);
payload.put("refundAmount", new BigDecimal("80.00"));
payload.put("refundReason", "API撤销-退款扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayOrderRefundInfoEntity refundInfo = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(order.getId());
assertThat(refundInfo).isNotNull();
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("60.00"));
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("80.00"));
refundIdsToCleanup.add(refundInfo.getId());
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
@@ -406,10 +514,155 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00"));
assertThat(counterLine.getClerkId()).isEqualTo(counterClerkId);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductLineRespectsFutureUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(3).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().plusHours(12).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "futureUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("220.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "frozen", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-锁定排期");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("frozen");
assertThat(counterLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductLineUnlocksImmediatelyWhenAlreadyAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(5).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().minusHours(1).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "pastUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("180.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("90.00"), "available", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-立即扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("45.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("available");
assertThat(counterLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductFailsWhenNoEarningLineExists() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(4);
PlayOrderInfoEntity order = persistOrder("RVK", "noLine", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("150.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-无收益扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("30.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("本单店员收益已全部扣回"));
}
@Test
void revokeCompletedOrder_rejectsDeductAmountBeyondAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(30);
PlayOrderInfoEntity order = persistOrder("RVK", "overDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("40.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-超额扣");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("扣回金额不能超过本单收益40.00"));
}
@Test
void revokeCompletedOrder_blocksNonNormalOrders() throws Exception {
ensureTenantContext();
@@ -424,7 +677,7 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
payload.put("orderId", giftOrder.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "gift revoke");
payload.put("earningsStrategy", "NONE");
payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
@@ -436,6 +689,30 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
}
@Test
void getRevocationLimits_returnsRemainingValues() throws Exception {
ensureTenantContext();
PlayOrderInfoEntity order = persistOrder("RVK", "limits", LocalDateTime.now().minusHours(3), entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("45.50"), "available");
earningsLineIdsToCleanup.add(earningId);
mockMvc.perform(get("/order/order/" + order.getId() + "/revocationLimits")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.maxRefundAmount").value(188.00))
.andExpect(jsonPath("$.data.maxDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.defaultDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.deductible").value(true));
}
private PlayOrderInfoEntity persistOrder(
String marker,
String token,
@@ -525,7 +802,11 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
return array;
}
private String seedLockedEarningLine(String orderId, BigDecimal amount, String status) {
private String seedEarningLine(String orderId, BigDecimal amount, String status) {
return seedEarningLine(orderId, amount, status, LocalDateTime.now().minusHours(2).withNano(0));
}
private String seedEarningLine(String orderId, BigDecimal amount, String status, LocalDateTime unlockAt) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-revoke-" + IdUtils.getUuid();
entity.setId(id);
@@ -535,14 +816,17 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
entity.setAmount(amount);
entity.setStatus(status);
entity.setEarningType(EarningsType.ORDER);
entity.setUnlockTime(LocalDateTime.now().minusHours(2));
entity.setUnlockTime(unlockAt);
if ("withdrawn".equals(status) || "withdrawing".equals(status)) {
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
}
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
ensureTenantContext();
earningsService.save(entity);
earningsLineIdsToCleanup.add(id);
return id;

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
/**
* 覆盖微信顾客端店员列表 / 详情在仅存在历史相册数据时的兼容行为。
*
* <p>重点校验:
* <ul>
* <li>旧字段 {@code album} 在列表接口中仍然可见;</li>
* <li>同时会被折叠进 {@code mediaList}且不会破坏后续媒资结构id/url/usage/status/reviewState</li>
* <li>详情接口中 {@link PlayClerkUserInfoResultVo#mediaList} 也包含这些历史相册。</li>
* </ul>
* </p>
*/
class WxClerkUserBackwardCompatApiTest extends AbstractApiTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
private final List<String> clerkIdsToCleanup = new ArrayList<>();
@AfterEach
void tearDown() {
ensureTenantContext();
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
clerkIdsToCleanup.clear();
}
}
/**
* 场景:只有旧相册(album) 而没有 play_media / play_clerk_media_asset 记录时,
* 微信顾客端列表接口仍然返回可用的 album 和 mediaList。
*/
@Test
void customerListIncludesLegacyAlbumAsMediaList() throws Exception {
ensureTenantContext();
String marker = "legacy-album-" + IdUtils.getUuid().substring(0, 8);
List<String> legacyAlbum = List.of(
"https://example.com/apitest/legacy/" + marker + "-a.png",
"https://example.com/apitest/legacy/" + marker + "-b.png");
String clerkId = createLegacyAlbumClerk(marker, legacyAlbum);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("pageNum", 1);
payload.put("pageSize", 20);
payload.put("nickname", marker);
payload.put("typeId", "");
payload.put("levelId", "");
payload.put("sex", "");
payload.put("province", "");
MvcResult result = mockMvc.perform(post("/wx/clerk/user/queryByPage")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andReturn();
String body = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(body);
assertThat(root.path("code").asInt()).as("response=%s", body).isEqualTo(200);
JsonNode records = root.path("data").path("records");
assertThat(records.isArray()).as("records should be array, response=%s", body).isTrue();
JsonNode target = null;
for (JsonNode record : records) {
if (clerkId.equals(record.path("id").asText())) {
target = record;
break;
}
}
assertThat(target)
.withFailMessage("列表中未找到目标店员 %s, response=%s", clerkId, body)
.isNotNull();
// album 字段仍然保留原有的历史值
JsonNode albumNode = target.path("album");
assertThat(albumNode.isArray()).isTrue();
Set<String> albumUrls = new HashSet<>();
albumNode.forEach(node -> albumUrls.add(node.asText()));
assertThat(albumUrls).containsExactlyInAnyOrderElementsOf(legacyAlbum);
// mediaList 中应当包含折叠后的 Legacy 媒资,并为其补齐 usage/status/reviewState 等字段
JsonNode mediaListNode = target.path("mediaList");
assertThat(mediaListNode.isArray()).isTrue();
assertThat(mediaListNode.size()).isEqualTo(legacyAlbum.size());
Set<String> mediaUrls = new HashSet<>();
for (JsonNode media : mediaListNode) {
String url = media.path("url").asText();
mediaUrls.add(url);
if (!legacyAlbum.contains(url)) {
continue;
}
// 对于从 album 折叠出来的条目,我们要求:
// id 与 url 相同usage/status/reviewState 均为兼容值。
assertThat(media.path("id").asText()).isEqualTo(url);
assertThat(media.path("usage").asText()).isEqualTo(ClerkMediaUsage.PROFILE.getCode());
assertThat(media.path("status").asText()).isEqualTo(MediaStatus.READY.getCode());
assertThat(media.path("reviewState").asText())
.isEqualTo(ClerkMediaReviewState.APPROVED.getCode());
}
assertThat(mediaUrls).containsAll(legacyAlbum);
}
/**
* 场景:只有旧相册(album) 数据时,顾客端店员详情接口 buildCustomerDetail 也会将其折叠进 mediaList
* 同时保留 album 字段不变。
*/
@Test
void customerDetailMergesLegacyAlbumIntoMediaList() {
ensureTenantContext();
String marker = "legacy-detail-" + IdUtils.getUuid().substring(0, 8);
List<String> legacyAlbum = List.of(
"https://example.com/apitest/legacy/" + marker + "-a.png",
"https://example.com/apitest/legacy/" + marker + "-b.png");
String clerkId = createLegacyAlbumClerk(marker, legacyAlbum);
PlayClerkUserInfoResultVo detail = clerkUserInfoService.buildCustomerDetail(clerkId, "");
assertThat(detail.getAlbum()).containsExactlyElementsOf(legacyAlbum);
List<MediaVo> mediaList = detail.getMediaList();
assertThat(mediaList).isNotNull();
assertThat(mediaList).hasSize(legacyAlbum.size());
Set<String> mediaUrls = new HashSet<>();
for (MediaVo media : mediaList) {
String url = media.getUrl();
mediaUrls.add(url);
if (!legacyAlbum.contains(url)) {
continue;
}
assertThat(media.getId()).isEqualTo(url);
assertThat(media.getUsage()).isEqualTo(ClerkMediaUsage.PROFILE.getCode());
assertThat(media.getStatus()).isEqualTo(MediaStatus.READY.getCode());
assertThat(media.getReviewState()).isEqualTo(ClerkMediaReviewState.APPROVED.getCode());
}
assertThat(mediaUrls).containsAll(legacyAlbum);
}
private String createLegacyAlbumClerk(String marker, List<String> album) {
String clerkId = IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setNickname("兼容测试店员-" + marker);
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
clerk.setClerkState("1");
clerk.setOnboardingState("1");
clerk.setListingState("1");
clerk.setDisplayState("1");
clerk.setRecommendationState("0");
clerk.setPinToTopState("0");
clerk.setRandomOrderState("1");
clerk.setFixingLevel("0");
clerk.setOnlineState("1");
clerk.setPhone("138" + clerkId.substring(0, 8));
clerk.setOpenid("openid-legacy-" + marker);
clerk.setWeiChatCode("wx-code-legacy-" + marker);
clerk.setTypeId("api-type-legacy");
clerk.setProvince("API省");
clerk.setCity("API市");
clerk.setEntryTime(LocalDateTime.now());
clerk.setAddTime(LocalDateTime.now());
clerk.setAlbum(new ArrayList<>(album));
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
}

View File

@@ -85,7 +85,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
String orderId = createdOrder.getId();
ensureTenantContext();
playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.GIFT.getCode())
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.NORMAL.getCode())
.eq(PlayOrderInfoEntity::getId, orderId)
.update();
ensureTenantContext();
@@ -112,7 +112,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
"\"orderId\":\"" + orderId + "\"," +
"\"refundToCustomer\":false," +
"\"refundReason\":\"" + revokeReason + "\"," +
"\"earningsStrategy\":\"NONE\"" +
"\"deductClerkEarnings\":false" +
"}";
mockMvc.perform(post("/order/order/revokeCompleted")
@@ -157,6 +157,71 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
}
}
@Test
void revokeCompletedOrderRejectsNonNormalOrderTypes() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String remark = "non-normal-" + IdUtils.getUuid();
placeRandomOrder(remark, customerToken);
ensureTenantContext();
PlayOrderInfoEntity createdOrder = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getRemark, remark)
.orderByDesc(PlayOrderInfoEntity::getCreatedTime)
.last("limit 1")
.one();
assertThat(createdOrder).isNotNull();
String orderId = createdOrder.getId();
ensureTenantContext();
playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.GIFT.getCode())
.eq(PlayOrderInfoEntity::getId, orderId)
.update();
ensureTenantContext();
playOrderInfoService.updateStateTo1(
OrderConstant.OperatorType.CLERK.getCode(),
ApiTestDataSeeder.DEFAULT_CLERK_ID,
ApiTestDataSeeder.DEFAULT_CLERK_ID,
orderId);
ensureTenantContext();
playOrderInfoService.updateStateTo23(
OrderConstant.OperatorType.CLERK.getCode(),
ApiTestDataSeeder.DEFAULT_CLERK_ID,
OrderConstant.OrderStatus.IN_PROGRESS.getCode(),
orderId);
ensureTenantContext();
playOrderInfoService.updateStateTo23(
OrderConstant.OperatorType.ADMIN.getCode(),
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
OrderConstant.OrderStatus.COMPLETED.getCode(),
orderId);
ObjectNode revokePayload = objectMapper.createObjectNode();
revokePayload.put("orderId", orderId);
revokePayload.put("refundToCustomer", false);
revokePayload.put("refundReason", "non-normal-type");
revokePayload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(revokePayload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageSkipsRechargeOrdersByDefaultButAllowsExplicitFilter() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);

View File

@@ -562,7 +562,8 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours);
Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock);
Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5));
Assertions.assertThat(earningsLine.getEarningType()).isEqualTo(EarningsType.ORDER);
Assertions.assertThat(earningsLine.getEarningType())
.isIn(EarningsType.ORDER, EarningsType.ADJUSTMENT);
OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd);
Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount)

View File

@@ -61,6 +61,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> withdrawalsToCleanup = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private String clerkToken;
private ClerkPayeeProfileEntity payeeProfile;
@@ -140,7 +141,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString());
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode data = root.get("data");
assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50");
assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40");
@@ -191,7 +192,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data.amount").value(amount.doubleValue()))
.andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString());
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank();
@@ -211,6 +212,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId);
}
@Test
void createWithdrawHandlesMixedPositiveAndNegativeLines() throws Exception {
ensureTenantContext();
LocalDateTime base = LocalDateTime.now().minusHours(4);
BigDecimal[] amounts = {
new BigDecimal("-30"),
new BigDecimal("20"),
new BigDecimal("50"),
new BigDecimal("-10"),
new BigDecimal("40"),
new BigDecimal("60"),
new BigDecimal("15"),
new BigDecimal("25"),
new BigDecimal("-5"),
new BigDecimal("100")};
String[] lineIds = new String[amounts.length];
for (int i = 0; i < amounts.length; i++) {
BigDecimal amount = amounts[i];
EarningsType type = amount.compareTo(BigDecimal.ZERO) < 0 ? EarningsType.ADJUSTMENT : EarningsType.ORDER;
String id = insertEarningsLine(
"mix-" + i,
amount,
EarningsStatus.AVAILABLE,
base.plusMinutes(i),
type);
lineIds[i] = id;
earningsToCleanup.add(id);
}
refreshPayeeConfirmation();
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
refreshPayeeConfirmation();
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
refreshPayeeConfirmation();
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
ensureTenantContext();
BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
assertThat(remaining).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
void earningsEndpointFiltersByStatus() throws Exception {
ensureTenantContext();
@@ -250,6 +297,15 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private String insertEarningsLine(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
}
private String insertEarningsLine(
String suffix,
BigDecimal amount,
EarningsStatus status,
LocalDateTime unlockAt,
EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-" + suffix + "-" + IdUtils.getUuid();
entity.setId(id);
@@ -260,7 +316,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
entity.setAmount(amount);
entity.setStatus(status.getCode());
entity.setUnlockTime(unlockAt);
entity.setEarningType(EarningsType.ORDER);
entity.setEarningType(earningType);
Date now = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(now);
@@ -274,6 +330,39 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
private void refreshPayeeConfirmation() {
if (payeeProfile != null) {
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
}
}
private String createWithdraw(BigDecimal amount) throws Exception {
MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"amount\":" + amount.toPlainString() + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank();
withdrawalsToCleanup.add(withdrawalId);
return withdrawalId;
}
private void assertLinesLocked(String withdrawalId, String... lineIds) {
ensureTenantContext();
for (String id : lineIds) {
EarningsLineEntity line = earningsService.getById(id);
assertThat(line.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode());
assertThat(line.getWithdrawalId()).isEqualTo(withdrawalId);
}
}
private Date toDate(LocalDateTime value) {
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
}

View File

@@ -0,0 +1,235 @@
package com.starry.admin.modules.clerk.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkDataType;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
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);
org.mockito.Mockito.lenient()
.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("店员信息不存在");
}
@Test
void enrichDataContentWithMediaPreviewPopulatesCoverAndVideoUrls() {
PlayClerkDataReviewInfoServiceImpl impl = service;
PlayClerkDataReviewReturnVo videoRow = new PlayClerkDataReviewReturnVo();
videoRow.setId("row-video");
videoRow.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
videoRow.setDataContent(java.util.Collections.singletonList("media-video"));
PlayClerkDataReviewReturnVo imageRow = new PlayClerkDataReviewReturnVo();
imageRow.setId("row-image");
imageRow.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
imageRow.setDataContent(java.util.Collections.singletonList("media-image"));
IPage<PlayClerkDataReviewReturnVo> page = new Page<>(1, 10);
page.setRecords(java.util.Arrays.asList(videoRow, imageRow));
PlayMediaEntity videoMedia = new PlayMediaEntity();
videoMedia.setId("media-video");
videoMedia.setKind(com.starry.admin.modules.media.enums.MediaKind.VIDEO.getCode());
videoMedia.setUrl("https://oss/video.mp4");
videoMedia.setCoverUrl("https://oss/video-cover.jpg");
PlayMediaEntity imageMedia = new PlayMediaEntity();
imageMedia.setId("media-image");
imageMedia.setKind(com.starry.admin.modules.media.enums.MediaKind.IMAGE.getCode());
imageMedia.setUrl("https://oss/image.png");
when(mediaService.getById("media-video")).thenReturn(videoMedia);
when(mediaService.getById("media-image")).thenReturn(imageMedia);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(
impl, "enrichDataContentWithMediaPreview", page);
assertThat(videoRow.getDataContent()).containsExactly("https://oss/video-cover.jpg");
assertThat(videoRow.getMediaVideoUrls()).containsExactly("https://oss/video.mp4");
assertThat(imageRow.getDataContent()).containsExactly("https://oss/image.png");
assertThat(imageRow.getMediaVideoUrls()).containsExactly((String) null);
}
@Test
void enrichDataContentWithMediaPreviewFallsBackToLegacyUrlWhenMediaMissing() {
PlayClerkDataReviewInfoServiceImpl impl = service;
String legacyUrl = "https://oss/legacy-only.png";
PlayClerkDataReviewReturnVo row = new PlayClerkDataReviewReturnVo();
row.setId("row-legacy");
row.setDataTypeEnum(ClerkDataType.PHOTO_ALBUM);
row.setDataContent(java.util.Collections.singletonList(legacyUrl));
IPage<PlayClerkDataReviewReturnVo> page = new Page<>(1, 10);
page.setRecords(java.util.Collections.singletonList(row));
// 当无法通过 mediaId 查询到记录时,应当保持 dataContent 为原始 URL
// 并且对应的 mediaVideoUrls 位置为 null。
when(mediaService.getById(legacyUrl)).thenReturn(null);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(
impl, "enrichDataContentWithMediaPreview", page);
assertThat(row.getDataContent()).containsExactly(legacyUrl);
assertThat(row.getMediaVideoUrls()).containsExactly((String) null);
}
@Test
void updateDataReviewStateUpdatesAvatarOnApproval() {
PlayClerkDataReviewInfoEntity review = buildReview("review-avatar", "clerk-1", "1",
java.util.Collections.singletonList("https://oss/avatar.png"));
// 审核服务内部通过 selectPlayClerkDataReviewInfoById 查询审核记录
org.mockito.Mockito.doReturn(review).when(service).selectPlayClerkDataReviewInfoById("review-avatar");
PlayClerkDataReviewStateEditVo vo = new PlayClerkDataReviewStateEditVo();
vo.setId("review-avatar");
vo.setReviewState(ClerkReviewState.APPROVED);
service.updateDataReviewState(vo);
ArgumentCaptor<PlayClerkUserInfoEntity> captor = ArgumentCaptor.forClass(PlayClerkUserInfoEntity.class);
verify(clerkUserInfoService).update(captor.capture());
PlayClerkUserInfoEntity updated = captor.getValue();
assertThat(updated.getId()).isEqualTo("clerk-1");
assertThat(updated.getAvatar()).isEqualTo("https://oss/avatar.png");
// 其他字段不应被误改
assertThat(updated.getNickname()).isNull();
assertThat(updated.getAudio()).isNull();
}
@Test
void updateDataReviewStateUpdatesAudioOnApproval() {
PlayClerkDataReviewInfoEntity review = buildReview("review-audio", "clerk-2", "3",
java.util.Collections.singletonList("https://oss/audio.m4a"));
org.mockito.Mockito.doReturn(review).when(service).selectPlayClerkDataReviewInfoById("review-audio");
PlayClerkDataReviewStateEditVo vo = new PlayClerkDataReviewStateEditVo();
vo.setId("review-audio");
vo.setReviewState(ClerkReviewState.APPROVED);
service.updateDataReviewState(vo);
ArgumentCaptor<PlayClerkUserInfoEntity> captor = ArgumentCaptor.forClass(PlayClerkUserInfoEntity.class);
verify(clerkUserInfoService, org.mockito.Mockito.atLeastOnce()).update(captor.capture());
PlayClerkUserInfoEntity updated = captor.getValue();
assertThat(updated.getId()).isEqualTo("clerk-2");
assertThat(updated.getAudio()).isEqualTo("https://oss/audio.m4a");
assertThat(updated.getNickname()).isNull();
assertThat(updated.getAvatar()).isNull();
}
private PlayClerkDataReviewInfoEntity buildReview(String id, String clerkId, String dataType,
java.util.List<String> payload) {
PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();
entity.setId(id);
entity.setClerkId(clerkId);
entity.setDataType(dataType);
entity.setDataContent(payload);
entity.setAddTime(LocalDateTime.now());
return entity;
}
}

View File

@@ -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<PlayClerkMediaAssetEntity> 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<java.util.List<PlayClerkMediaAssetEntity>> batches = new java.util.ArrayList<>();
org.mockito.Mockito.doAnswer(inv -> {
java.util.Collection<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> 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<java.util.List<PlayClerkMediaAssetEntity>> batches = new java.util.ArrayList<>();
org.mockito.Mockito.doAnswer(inv -> {
java.util.Collection<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> 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<PlayClerkMediaAssetEntity> updates, String id) {
return updates.stream()
.filter(asset -> id.equals(asset.getId()))
.findFirst()
.orElseThrow();
}
@SuppressWarnings("unchecked")
private static List<PlayClerkMediaAssetEntity> flatten(List<? extends List<PlayClerkMediaAssetEntity>> captured) {
return captured.stream()
.flatMap(List::stream)
.collect(Collectors.toCollection(ArrayList::new));
}
}

View File

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

View File

@@ -4,7 +4,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -29,46 +28,49 @@ class OrderRevocationEarningsListenerTest {
}
@Test
void handle_counterStrategyFallsBackToEstimatedRevenueWhenRefundAmountZero() {
void handle_deductCreatesCounterLineUsingOrderClerk() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-counter-1");
context.setOperatorId("admin-op");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI);
context.setRefundAmount(BigDecimal.ZERO);
context.setCounterClerkId("ops-clerk");
context.setOrderId("order-reverse-2");
context.setOperatorId("admin-reviewer");
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(25));
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-counter-1");
order.setTenantId("tenant-77");
order.setEstimatedRevenue(BigDecimal.valueOf(68));
order.setId("order-reverse-2");
order.setTenantId("tenant-x");
order.setAcceptBy("clerk-special");
listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService)
.createCounterLine(order.getId(), order.getTenantId(), "ops-clerk", BigDecimal.valueOf(68), "admin-op");
.createCounterLine("order-reverse-2", "tenant-x", "clerk-special", BigDecimal.valueOf(25), "admin-reviewer");
}
@Test
void handle_reverseStrategyRevertsAvailableLines() {
void handle_deductFallsBackToEstimatedWhenAmountMissing() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-2");
context.setOrderId("order-reverse-3");
context.setOperatorId("admin-reviewer");
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-reverse-2");
order.setId("order-reverse-3");
order.setTenantId("tenant-y");
order.setAcceptBy("clerk-owner");
order.setEstimatedRevenue(BigDecimal.valueOf(52));
listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService).reverseByOrder("order-reverse-2", "admin-reviewer");
verify(earningsService)
.createCounterLine("order-reverse-3", "tenant-y", "clerk-owner", BigDecimal.valueOf(52), "admin-reviewer");
}
@Test
void handle_noneStrategyIsNoOp() {
void handle_disabledDeductIsNoOp() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-none-3");
context.setOperatorId("admin-noop");
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-none-3");

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.order.service.impl;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
@@ -43,7 +44,6 @@ import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -132,8 +132,12 @@ class OrderLifecycleServiceImplTest {
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.TEN);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
.thenReturn(true);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.valueOf(60));
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
@@ -142,7 +146,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(88));
context.setRefundReason("客户投诉");
context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
lifecycleService.revokeCompletedOrder(context);
@@ -152,8 +156,9 @@ class OrderLifecycleServiceImplTest {
verify(applicationEventPublisher).publishEvent(captor.capture());
OrderRevocationEvent event = captor.getValue();
assertEquals(orderId, event.getContext().getOrderId());
assertEquals(EarningsAdjustStrategy.REVERSE_CLERK, event.getContext().getEarningsStrategy());
assertTrue(event.getContext().isDeductClerkEarnings());
assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount());
assertEquals(BigDecimal.valueOf(60), event.getContext().getEarningsAdjustAmount());
}
@Test
@@ -177,7 +182,7 @@ class OrderLifecycleServiceImplTest {
context.setRefundAmount(BigDecimal.valueOf(108));
context.setRefundReason("质量问题");
context.setRefundToCustomer(true);
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
lifecycleService.revokeCompletedOrder(context);
@@ -186,52 +191,74 @@ class OrderLifecycleServiceImplTest {
}
@Test
void revokeCompletedOrder_blocksWhenEarningsLocked() {
void revokeCompletedOrder_reverseStrategyDefaultsCounterClerkWhenMissing() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed);
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.TEN);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-locked");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK);
context.setDeductClerkEarnings(true);
assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper, never()).update(isNull(), any());
verify(applicationEventPublisher, never()).publishEvent(any());
context.setEarningsAdjustAmount(BigDecimal.valueOf(10));
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
}
@Test
void revokeCompletedOrder_counterStrategyAllowedAfterWithdrawal() {
void revokeCompletedOrder_throwsWhenDeductExceedsAvailable() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
completed.setEstimatedRevenue(BigDecimal.valueOf(120));
completed.setFinalAmount(BigDecimal.valueOf(200));
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
revoked.setFinalAmount(BigDecimal.valueOf(200));
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.valueOf(40));
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-counter");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.valueOf(50));
context.setRefundReason("撤销并转待扣");
context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI);
context.setCounterClerkId("clerk-negative");
context.setRefundAmount(BigDecimal.ZERO);
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(50));
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("扣回金额不能超过本单收益40", ex.getMessage());
}
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
@Test
void revokeCompletedOrder_throwsWhenNoEarningsToDeduct() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
when(earningsService.getRemainingEarningsForOrder(orderId, completed.getAcceptBy()))
.thenReturn(BigDecimal.ZERO);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-empty");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.ZERO);
context.setDeductClerkEarnings(true);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("本单店员收益已全部扣回", ex.getMessage());
}
@Test
@@ -246,7 +273,7 @@ class OrderLifecycleServiceImplTest {
context.setOrderId(orderId);
context.setOperatorId("admin-block");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setEarningsStrategy(EarningsAdjustStrategy.NONE);
context.setDeductClerkEarnings(false);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("仅支持撤销普通服务订单", ex.getMessage());
@@ -1359,6 +1386,7 @@ class OrderLifecycleServiceImplTest {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId);
entity.setOrderStatus(status);
entity.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
entity.setAcceptBy("clerk-1");
entity.setPurchaserBy("customer-1");
entity.setTenantId("tenant-1");

View File

@@ -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");
}
}

View File

@@ -0,0 +1,137 @@
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(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<PlayMediaEntity> 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;
}
}

View File

@@ -1,6 +1,8 @@
package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -14,6 +16,7 @@ import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -83,8 +86,21 @@ class EarningsServiceImplTest {
verify(baseMapper, never()).insert(any());
}
@Test
void createCounterLine_throwsWhenNoReferencePresent() {
when(baseMapper.selectList(any())).thenReturn(Collections.emptyList());
assertThrows(IllegalStateException.class, () ->
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.TEN, "admin"));
}
@Test
void createCounterLine_persistsNegativeAvailableLine() {
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("88.00"));
reference.setUnlockTime(LocalDateTime.now().minusHours(1));
reference.setStatus("available");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin");
@@ -97,6 +113,46 @@ class EarningsServiceImplTest {
assertEquals("available", saved.getStatus());
}
@Test
void createCounterLine_inheritsFrozenUnlockScheduleFromReference() {
LocalDateTime unlockAt = LocalDateTime.now().plusDays(1).withNano(0);
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("150.00"));
reference.setUnlockTime(unlockAt);
reference.setStatus("frozen");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(40), "admin");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals("frozen", saved.getStatus());
assertEquals(unlockAt, saved.getUnlockTime());
}
@Test
void createCounterLine_unlockedReferenceProducesAvailableCounter() {
LocalDateTime unlockAt = LocalDateTime.now().minusHours(3).withNano(0);
EarningsLineEntity reference = new EarningsLineEntity();
reference.setAmount(new BigDecimal("95.00"));
reference.setUnlockTime(unlockAt);
reference.setStatus("available");
when(baseMapper.selectList(any())).thenReturn(Collections.singletonList(reference));
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(55), "admin");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals("available", saved.getStatus());
LocalDateTime now = LocalDateTime.now();
assertTrue(saved.getUnlockTime().isAfter(unlockAt));
assertTrue(!saved.getUnlockTime().isAfter(now.plusSeconds(1)));
}
@Test
void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() {
LocalDateTime now = LocalDateTime.now();
@@ -125,6 +181,34 @@ class EarningsServiceImplTest {
assertEquals("second", picked.get(2).getId());
}
@Test
void findWithdrawable_handlesMixedPositiveAndNegativeSequences() {
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg-30", new BigDecimal("-30")),
line("pos-20", new BigDecimal("20")),
line("pos-50", new BigDecimal("50")),
line("neg-10", new BigDecimal("-10")),
line("pos-40", new BigDecimal("40")),
line("pos-60", new BigDecimal("60")),
line("pos-15", new BigDecimal("15")),
line("pos-25", new BigDecimal("25")),
line("neg-5", new BigDecimal("-5")),
line("pos-100", new BigDecimal("100")));
when(baseMapper.selectWithdrawableLines("clerk-mix", now)).thenReturn(lines, lines, lines);
List<EarningsLineEntity> partial = earningsService.findWithdrawable("clerk-mix", new BigDecimal("35"), now);
assertEquals(Arrays.asList("neg-30", "pos-20", "pos-50"), ids(partial));
List<EarningsLineEntity> mid = earningsService.findWithdrawable("clerk-mix", new BigDecimal("90"), now);
assertEquals(Arrays.asList("neg-30", "pos-20", "pos-50", "neg-10", "pos-40", "pos-60"), ids(mid));
List<EarningsLineEntity> full = earningsService.findWithdrawable("clerk-mix", new BigDecimal("265"), now);
assertEquals(lines.size(), full.size());
assertEquals("pos-100", full.get(full.size() - 1).getId());
}
private EarningsLineEntity line(String id, BigDecimal amount) {
EarningsLineEntity entity = new EarningsLineEntity();
entity.setId(id);
@@ -132,4 +216,8 @@ class EarningsServiceImplTest {
entity.setStatus("available");
return entity;
}
private List<String> ids(List<EarningsLineEntity> entities) {
return entities.stream().map(EarningsLineEntity::getId).collect(java.util.stream.Collectors.toList());
}
}