Compare commits

...

34 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
irving
cc76710858 feat: guard revocation for normal orders
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-13 15:46:09 -05:00
irving
b51aac0cfa fix test 2025-11-13 14:58:05 -05:00
irving
ee0fc4d1f6 feat: unify admin order keyword search 2025-11-13 14:58:05 -05:00
hucs-dev
9d20040574 fix: code style 2025-11-12 16:57:01 +08:00
hucs-dev
2f807a2796 fix: 🚀礼物分页bug 2025-11-12 16:45:12 +08:00
irving
49867a30dd fix: stabilize order api tests
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-11 22:22:48 -05:00
irving
51c4a5438d feat: improve wechat order query coverage 2025-11-11 20:48:20 -05:00
irving
e616dd6a13 WIP 2025-11-10 23:42:00 -05:00
irving
ed0edf584a Merge branch 'feat/performance-filtering' 2025-11-10 22:39:31 -05:00
irving
b9250566fb test: cover clerk performance date ranges 2025-11-10 22:33:27 -05:00
irving
7b6943d391 fix: allow editing blind box pools referencing inactive gifts
Some checks failed
Build and Push Backend / docker (push) Failing after 7s
2025-11-10 21:59:59 -05:00
irving
984e33bd94 add back up dev db script 2025-11-10 21:17:13 -05:00
irving
4fdcf6ddbd fix test
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-11-08 20:31:30 -05:00
irving
7d07e32271 feat: enrich withdrawal audit info 2025-11-08 20:09:07 -05:00
irving
438aef7af7 fix: ignore null level prices when updating commodity 2025-11-08 20:06:15 -05:00
85 changed files with 7078 additions and 166 deletions

18
backup-dev-db.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
DB_HOST="primary"
DB_PORT="3306"
DB_NAME="play-with"
DB_USER="root"
DB_PASSWORD="123456"
stamp="$(date +%F)"
backup_dir="yunpei/backup/dev/${stamp}"
mkdir -p "${backup_dir}"
echo "[backup] dumping ${DB_NAME} from ${DB_HOST}:${DB_PORT} -> ${backup_dir}/dev.sql.gz"
mysqldump -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
| gzip > "${backup_dir}/dev.sql.gz"
echo "[backup] done"

View File

@@ -1,7 +1,7 @@
version: "3.9" version: "3.9"
services: services:
mysql-apitest: mysql-apitest:
image: mysql:8.0.24 image: mysql:8.0
container_name: peipei-mysql-apitest container_name: peipei-mysql-apitest
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -22,3 +22,11 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 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,21 @@
package com.starry.admin.common;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public class PageBuilder {
public static final String PAGE_NUM = "pageNum";
public static final String PAGE_SIZE = "pageSize";
public static <T> Page<T> build() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Integer pageNum = Integer.valueOf(attributes.getRequest().getParameter(PAGE_NUM));
Integer pageSize = Integer.valueOf(attributes.getRequest().getParameter(PAGE_SIZE));
return new Page<>(pageNum, pageSize);
}
}

View File

@@ -162,7 +162,7 @@ public class BlindBoxPoolAdminService {
if (!tenantId.equals(config.getTenantId())) { if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除"); throw new CustomException("盲盒不存在或已被移除");
} }
PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId()); PlayGiftInfoEntity rewardGift = requireRewardGiftForUpdate(tenantId, request.getRewardGiftId());
validateTimeRange(request.getValidFrom(), request.getValidTo()); validateTimeRange(request.getValidFrom(), request.getValidTo());
Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName()); Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName());
Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName()); Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName());
@@ -326,18 +326,30 @@ public class BlindBoxPoolAdminService {
} }
private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) { private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) {
return requireRewardGift(tenantId, rewardGiftId, true);
}
private PlayGiftInfoEntity requireRewardGiftForUpdate(String tenantId, String rewardGiftId) {
return requireRewardGift(tenantId, rewardGiftId, false);
}
private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId, boolean strictAvailability) {
if (StrUtil.isBlank(rewardGiftId)) { if (StrUtil.isBlank(rewardGiftId)) {
throw new CustomException("请选择中奖礼物"); throw new CustomException("请选择中奖礼物");
} }
PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId); PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId);
if (gift == null if (gift == null
|| !tenantId.equals(gift.getTenantId()) || !tenantId.equals(gift.getTenantId())
|| !GiftHistory.CURRENT.getCode().equals(gift.getHistory())
|| !GiftState.ACTIVE.getCode().equals(gift.getState())
|| !GiftType.NORMAL.getCode().equals(gift.getType()) || !GiftType.NORMAL.getCode().equals(gift.getType())
|| Boolean.TRUE.equals(gift.getDeleted())) { || Boolean.TRUE.equals(gift.getDeleted())) {
throw new CustomException("中奖礼物不存在或已下架"); throw new CustomException("中奖礼物不存在或已下架");
} }
if (strictAvailability) {
if (!GiftHistory.CURRENT.getCode().equals(gift.getHistory())
|| !GiftState.ACTIVE.getCode().equals(gift.getState())) {
throw new CustomException("中奖礼物不存在或已下架");
}
}
return gift; return gift;
} }

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; package com.starry.admin.modules.clerk.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import java.util.List;
import org.apache.ibatis.annotations.Select;
/** /**
* 店员Mapper接口 * 店员Mapper接口
@@ -11,4 +14,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
*/ */
public interface PlayClerkUserInfoMapper extends MPJBaseMapper<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.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; 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.ApiModel;
import io.swagger.annotations.ApiModelProperty; import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -94,6 +95,12 @@ public class PlayClerkUserReturnVo {
@ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表") @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>(); 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 = "资料内容,根据资料类型有不同格式") @ApiModelProperty(value = "资料内容", example = "[\"https://example.com/photo1.jpg\"]", notes = "资料内容,根据资料类型有不同格式")
private List<String> dataContent; private List<String> dataContent;
/**
* 媒资对应的视频地址(仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应)
*/
@ApiModelProperty(
value = "媒资视频地址列表",
example = "[\"https://example.com/video1.mp4\"]",
notes = "仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应")
private List<String> mediaVideoUrls;
/** /**
* 审核状态0未审核:1审核通过2审核不通过 * 审核状态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); 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(); List<PlayClerkUserInfoEntity> simpleList();
/**
* 查询存在相册字段数据的店员(忽略租户隔离)
*
* @return 店员集合
*/
List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant();
JSONObject getPcData(PlayClerkUserInfoEntity entity); JSONObject getPcData(PlayClerkUserInfoEntity entity);
} }

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl; package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; 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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException; 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.mapper.PlayClerkDataReviewInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity; 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.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity; 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.PlayClerkDataReviewQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; 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.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.enums.ClerkReviewState;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -42,6 +56,12 @@ public class PlayClerkDataReviewInfoServiceImpl
@Resource @Resource
private IPlayClerkUserInfoService playClerkUserInfoService; 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), lambdaQueryWrapper.between(PlayClerkDataReviewInfoEntity::getAddTime, vo.getAddTime().get(0),
vo.getAddTime().get(1)); vo.getAddTime().get(1));
} }
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), IPage<PlayClerkDataReviewReturnVo> page = this.baseMapper.selectJoinPage(
PlayClerkDataReviewReturnVo.class, lambdaQueryWrapper); new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkDataReviewReturnVo.class,
lambdaQueryWrapper);
enrichDataContentWithMediaPreview(page);
return page;
} }
/** /**
@@ -129,6 +152,72 @@ public class PlayClerkDataReviewInfoServiceImpl
return save(playClerkDataReviewInfo); 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 @Override
public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) { public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) {
PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId()); PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId());
@@ -147,7 +236,8 @@ public class PlayClerkDataReviewInfoServiceImpl
userInfo.setAvatar(entity.getDataContent().get(0)); userInfo.setAvatar(entity.getDataContent().get(0));
} }
if ("2".equals(entity.getDataType())) { if ("2".equals(entity.getDataType())) {
userInfo.setAlbum(entity.getDataContent()); userInfo.setAlbum(new ArrayList<>());
synchronizeApprovedAlbumMedia(entity);
} }
if ("3".equals(entity.getDataType())) { if ("3".equals(entity.getDataType())) {
userInfo.setAudio(entity.getDataContent().get(0)); 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) { public int deletePlayClerkDataReviewInfoById(String id) {
return playClerkDataReviewInfoMapper.deleteById(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; package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.component.JwtToken;
import com.starry.admin.common.domain.LoginUser; import com.starry.admin.common.domain.LoginUser;
import com.starry.admin.common.exception.CustomException; 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.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity; 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.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; 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.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserQueryVo; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserQueryVo;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReturnVo; 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.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; 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.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity; import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService; 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.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity; 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.personnel.service.IPlayPersonnelWaiterInfoService;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.system.service.LoginService; 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.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.PlayClerkUserInfoQueryVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo; import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
@@ -53,7 +63,9 @@ import com.starry.common.utils.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -69,9 +81,7 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30 * @since 2024-03-30
*/ */
@Service @Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
implements
IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员"; private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问"; private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
@@ -87,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Resource @Resource
private IPlayCustomFollowInfoService customFollowInfoService; private IPlayCustomFollowInfoService customFollowInfoService;
@Resource @Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource @Resource
private IPlayOrderInfoService playOrderInfoService; private IPlayOrderInfoService playOrderInfoService;
@@ -131,8 +145,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class); lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId"); lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId); lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper); PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) { if (levelInfo != null) {
@@ -157,8 +170,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 查询店员 * 查询店员
* *
* @param id * @param id 店员主键
* 店员主键
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -173,13 +185,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override @Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) { public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class); PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) { if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream() Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
if (pendingTypes.contains("0")) { if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false); result.setNicknameAllowEdit(false);
} }
@@ -217,18 +225,19 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
// 查询店员服务项目 // 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>(); List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) { for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
clerkCommodityEntity.getEnablingState()));
} }
result.setCommodity(playClerkCommodityQueryVos); result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity()); result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
result.setPcData(this.getPcData(userInfo)); result.setPcData(this.getPcData(userInfo));
result.setLevelInfo(playClerkLevelInfoService.selectPlayClerkLevelInfoById(userInfo.getLevelId())); 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; return result;
} }
@@ -265,10 +274,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isBlank(clerkId)) { if (StrUtil.isBlank(clerkId)) {
return; return;
} }
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class) LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
.eq(PlayClerkUserInfoEntity::getId, clerkId)
.set(PlayClerkUserInfoEntity::getToken, "empty")
.set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper); this.baseMapper.update(null, wrapper);
} }
@@ -286,21 +292,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
@Override @Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
// 修改用户余额 // 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation)); this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录 // 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
} }
/** /**
* 查询店员列表 * 查询店员列表
* *
* @param vo * @param vo 店员查询对象
* 店员查询对象
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -311,12 +313,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的 // 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1"); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段 // 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
"address");
// 等级表 // 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName"); lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
// 服务项目表 // 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) { 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) .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 @Override
@@ -364,8 +386,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) { public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员 // 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname") lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选 // 加入组员的筛选
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null); 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.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
} }
// 查询店员订单信息 // 查询店员订单信息
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities); lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0"); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
} }
@Override @Override
@@ -483,12 +501,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序 // 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState) lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage( IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) { for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0"); BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -508,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
record.setOrderContinueNumber(String.valueOf(orderContinueNumber)); record.setOrderContinueNumber(String.valueOf(orderContinueNumber));
} }
attachMediaToAdminVos(page.getRecords());
return page; return page;
} }
@@ -519,10 +535,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) { if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId); lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
.list(lambdaQueryWrapper); customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
} }
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) { for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0"); record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -534,11 +548,37 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
return voPage; 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 结果 * @return 结果
*/ */
@Override @Override
@@ -552,16 +592,12 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 修改店员 * 修改店员
* *
* @param playClerkUserInfo * @param playClerkUserInfo 店员
* 店员
* @return 结果 * @return 结果
*/ */
@Override @Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) { public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
&& (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getListingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null; PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) { if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId()); beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
@@ -576,8 +612,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 批量删除店员 * 批量删除店员
* *
* @param ids * @param ids 需要删除的店员主键
* 需要删除的店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -588,8 +623,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 删除店员信息 * 删除店员信息
* *
* @param id * @param id 店员主键
* 店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -603,13 +637,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId); lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
} }
@Override
public List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant() {
return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant();
}
@Override @Override
public JSONObject getPcData(PlayClerkUserInfoEntity entity) { public JSONObject getPcData(PlayClerkUserInfoEntity entity) {
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
@@ -621,8 +658,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId()); LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo); Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token")); data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) { if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator"); data.fluentPut("role", "operator");
return data; return data;
@@ -632,8 +668,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader"); data.fluentPut("role", "leader");
return data; return data;
} }
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) { if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter"); data.fluentPut("role", "waiter");
return data; return data;
@@ -645,13 +680,101 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (beforeUpdate == null) { if (beforeUpdate == null) {
return; return;
} }
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
beforeUpdate.getOnboardingState())
|| ListingStatus.transitionedToDelisted(updatedPayload.getListingState(),
beforeUpdate.getListingState())
|| ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(),
beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId()); 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

@@ -7,6 +7,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; 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.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*; import com.starry.admin.modules.order.module.vo.*;
import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IOrderLifecycleService;
@@ -14,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.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService; 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.admin.utils.SecurityUtils;
import com.starry.common.annotation.Log; import com.starry.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
@@ -27,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -58,6 +61,9 @@ public class PlayOrderInfoController {
@Resource @Resource
private IPlayClerkUserInfoService clerkUserInfoService; private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/** /**
* 分页查询订单列表 * 分页查询订单列表
*/ */
@@ -106,6 +112,46 @@ public class PlayOrderInfoController {
return R.ok("退款成功"); return R.ok("退款成功");
} }
@ApiOperation(value = "撤销已完成订单", notes = "管理员操作撤销,支持可选退款与收益处理")
@PostMapping("/revokeCompleted")
public R revokeCompleted(@Validated @RequestBody PlayOrderRevocationVo vo) {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(vo.getOrderId());
context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason());
context.setDeductClerkEarnings(vo.isDeductClerkEarnings());
context.setEarningsAdjustAmount(vo.getDeductAmount());
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
orderLifecycleService.revokeCompletedOrder(context);
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

@@ -0,0 +1,54 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationBalanceListener {
private final IPlayCustomUserInfoService customUserInfoService;
public OrderRevocationBalanceListener(IPlayCustomUserInfoService customUserInfoService) {
this.customUserInfoService = customUserInfoService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
if (!event.getContext().isRefundToCustomer()) {
return;
}
BigDecimal refundAmount = Optional.ofNullable(event.getContext().getRefundAmount()).orElse(BigDecimal.ZERO);
if (refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
PlayCustomUserInfoEntity customer = customUserInfoService.getById(order.getPurchaserBy());
if (customer == null) {
throw new CustomException("顾客信息不存在");
}
BigDecimal currentBalance = Optional.ofNullable(customer.getAccountBalance()).orElse(BigDecimal.ZERO);
customUserInfoService.updateAccountBalanceById(
customer.getId(),
currentBalance,
currentBalance.add(refundAmount),
BalanceOperationType.REFUND.getCode(),
"已完成订单撤销退款",
refundAmount,
BigDecimal.ZERO,
order.getId());
}
}

View File

@@ -0,0 +1,55 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
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;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationEarningsListener {
private final IEarningsService earningsService;
public OrderRevocationEarningsListener(IEarningsService earningsService) {
this.earningsService = earningsService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
OrderRevocationContext context = event.getContext();
if (!context.isDeductClerkEarnings()) {
return;
}
createCounterLine(event);
}
private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext();
if (context == null) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = order.getAcceptBy();
if (targetClerkId == null || targetClerkId.trim().isEmpty()) {
throw new CustomException("需要指定收益冲销目标账号");
}
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

@@ -19,7 +19,8 @@ public class OrderConstant {
ACCEPTED("1", "已接单(待开始)"), ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"), IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"), COMPLETED("3", "已完成"),
CANCELLED("4", "已取消"); CANCELLED("4", "已取消"),
REVOKED("5", "已撤销");
private final String code; private final String code;
private final String description; private final String description;

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.lang.Nullable;
@Data
public class OrderRevocationContext {
@NotBlank
private String orderId;
@Nullable
private String operatorId;
@Nullable
private String operatorType;
@Nullable
private BigDecimal refundAmount;
@Nullable
private String refundReason;
private boolean refundToCustomer;
private boolean deductClerkEarnings;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
@Nullable
private BigDecimal earningsAdjustAmount;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource;
return this;
}
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.order.module.event;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import lombok.Getter;
@Getter
public class OrderRevocationEvent {
private final OrderRevocationContext context;
private final PlayOrderInfoEntity orderSnapshot;
public OrderRevocationEvent(OrderRevocationContext context, PlayOrderInfoEntity orderSnapshot) {
this.context = context;
this.orderSnapshot = orderSnapshot;
}
}

View File

@@ -30,6 +30,12 @@ public class PlayOrderInfoQueryVo extends BasePageEntity {
@ApiModelProperty(value = "订单编号", example = "ORDER20240320001", notes = "订单的编号,支持模糊查询") @ApiModelProperty(value = "订单编号", example = "ORDER20240320001", notes = "订单的编号,支持模糊查询")
private String orderNo; private String orderNo;
/**
* 统一关键字(订单号或店员昵称)
*/
@ApiModelProperty(value = "关键词", example = "ORDER20240320001", notes = "支持订单号或店员昵称模糊查询")
private String keyword;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消
*/ */

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

@@ -0,0 +1,31 @@
package com.starry.admin.modules.order.module.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
@ApiModel(value = "订单撤销参数", description = "撤销已完成订单的请求参数")
public class PlayOrderRevocationVo {
@NotBlank(message = "订单ID不能为空")
@ApiModelProperty(value = "订单ID", required = true)
private String orderId;
@ApiModelProperty(value = "是否退还顾客余额")
private boolean refundToCustomer;
@ApiModelProperty(value = "退款金额,未填写则默认订单实付金额")
private BigDecimal refundAmount;
@ApiModelProperty(value = "撤销原因")
private String refundReason;
@ApiModelProperty(value = "是否扣回店员收益")
private boolean deductClerkEarnings;
@ApiModelProperty(value = "扣回金额,未填写则默认按本单收益全额扣回")
private BigDecimal deductAmount;
}

View File

@@ -5,6 +5,7 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; 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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; 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.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService { public interface IOrderLifecycleService {
@@ -14,4 +15,6 @@ public interface IOrderLifecycleService {
void completeOrder(String orderId, OrderCompletionContext context); void completeOrder(String orderId, OrderCompletionContext context);
void refundOrder(OrderRefundContext context); void refundOrder(OrderRefundContext context);
void revokeCompletedOrder(OrderRevocationContext context);
} }

View File

@@ -199,6 +199,8 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
*/ */
List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId); List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId);
void revokeCompletedOrder(OrderRevocationContext context);
/** /**
* 修改订单状态为接单 只有管理员或者店员本人才能操作 * 修改订单状态为接单 只有管理员或者店员本人才能操作
* *

View File

@@ -34,10 +34,12 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; 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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; 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.PaymentInfo; 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.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo; import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
@@ -61,9 +63,11 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -77,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private enum LifecycleOperation { private enum LifecycleOperation {
CREATE, CREATE,
COMPLETE, COMPLETE,
REFUND REFUND,
REVOKE_COMPLETED
} }
@Resource @Resource
@@ -110,6 +115,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies; private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct @PostConstruct
@@ -520,7 +528,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~"); throw new CustomException("每个订单只能退款一次~");
} }
if (isBalancePaidOrder(order) && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) { if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款"); throw new CustomException("订单未发生余额扣款,无法退款");
} }
@@ -603,6 +612,123 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
} }
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单ID不能为空");
}
PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId());
if (order == null) {
throw new CustomException("订单不存在");
}
if (OrderStatus.REVOKED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("订单已撤销");
}
if (!OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("当前状态无法撤销");
}
if (!OrderConstant.OrderType.NORMAL.getCode().equals(order.getOrderType())) {
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();
if (refundAmount == null) {
refundAmount = context.isRefundToCustomer() ? finalAmount : BigDecimal.ZERO;
}
if (refundAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomException("退款金额不能小于0");
}
if (refundAmount.compareTo(finalAmount) > 0) {
throw new CustomException("退款金额不能大于支付金额");
}
context.setRefundAmount(refundAmount);
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
}
UpdateWrapper<PlayOrderInfoEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", order.getId())
.eq("order_status", OrderStatus.COMPLETED.getCode())
.set("order_status", OrderStatus.REVOKED.getCode())
.set("order_cancel_time", LocalDateTime.now())
.set("refund_amount", refundAmount)
.set("refund_reason", context.getRefundReason());
if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
updateWrapper.set("refund_type", OrderRefundFlag.REFUNDED.getCode());
}
boolean updated = orderInfoMapper.update(null, updateWrapper) > 0;
PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId());
if (!updated && (latest == null || !OrderStatus.REVOKED.getCode().equals(latest.getOrderStatus()))) {
throw new CustomException("订单状态已变化,无法撤销");
}
if (latest == null) {
latest = order;
latest.setOrderStatus(OrderStatus.REVOKED.getCode());
}
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
OrderRefundRecordType recordType = finalAmount.compareTo(refundAmount) == 0
? OrderRefundRecordType.FULL
: OrderRefundRecordType.PARTIAL;
orderRefundInfoService.add(
latest.getId(),
latest.getPurchaserBy(),
latest.getAcceptBy(),
latest.getPayMethod(),
recordType.getCode(),
refundAmount,
context.getRefundReason(),
context.getOperatorType(),
context.getOperatorId(),
OrderRefundState.PROCESSING.getCode(),
ReviewRequirement.NOT_REQUIRED.getCode());
}
OrderActor actor = resolveCompletionActor(context.getOperatorType());
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);
applicationEventPublisher.publishEvent(new OrderRevocationEvent(context, latest));
}
private void validateOrderCreationRequest(OrderCreationContext context) { private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) { if (context == null) {
throw new CustomException("订单创建请求不能为空"); throw new CustomException("订单创建请求不能为空");
@@ -747,6 +873,10 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
} }
private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) { private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) {
if (OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode().equals(order.getOrderType())) {
log.debug("Skip earnings creation for blind box purchase order {}", order.getId());
return false;
}
Long existing = earningsService.lambdaQuery() Long existing = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, order.getTenantId()) .eq(EarningsLineEntity::getTenantId, order.getTenantId())
.eq(EarningsLineEntity::getOrderId, order.getId()) .eq(EarningsLineEntity::getOrderId, order.getId())

View File

@@ -402,7 +402,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public IPage<PlayOrderInfoReturnVo> selectOrderInfoPage(PlayOrderInfoQueryVo vo) { public IPage<PlayOrderInfoReturnVo> selectOrderInfoPage(PlayOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class)); ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), vo.getKeyword());
lambdaQueryWrapper.in(PlayOrderInfoEntity::getPlaceType, "0", "1", "2"); lambdaQueryWrapper.in(PlayOrderInfoEntity::getPlaceType, "0", "1", "2");
if (StringUtils.isNotBlank(vo.getGroupId())) { if (StringUtils.isNotBlank(vo.getGroupId())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getGroupId, vo.getGroupId()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getGroupId, vo.getGroupId());
@@ -454,7 +454,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public PlayClerkOrderDetailsReturnVo clerkSelectOrderDetails(String clerkId, String orderId) { public PlayClerkOrderDetailsReturnVo clerkSelectOrderDetails(String clerkId, String orderId) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
// 拼接用户等级 // 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId") lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName"); .selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -505,7 +505,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public IPage<PlayClerkOrderListReturnVo> clerkSelectOrderInfoByPage(PlayClerkOrderInfoQueryVo vo) { public IPage<PlayClerkOrderListReturnVo> clerkSelectOrderInfoByPage(PlayClerkOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class)); ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), null);
// 拼接用户等级 // 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId") lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName"); .selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -520,7 +520,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
entity.setPurchaserBy(customId); entity.setPurchaserBy(customId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class, PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class,
lambdaQueryWrapper); lambdaQueryWrapper);
// 如果订单状态为退款,查询订单退款原因 // 如果订单状态为退款,查询订单退款原因
@@ -546,7 +546,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) { public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class)); ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), null);
if (StringUtils.isBlank(vo.getOrderType())) {
lambdaQueryWrapper.notIn(PlayOrderInfoEntity::getOrderType,
OrderConstant.OrderType.RECHARGE.getCode(),
OrderConstant.OrderType.WITHDRAWAL.getCode());
}
IPage<PlayCustomOrderListReturnVo> page = this.baseMapper.selectJoinPage( IPage<PlayCustomOrderListReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper); new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper);
// 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构 // 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构
@@ -706,7 +711,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
* *
* @return MPJLambdaWrapper<PlayOrderInfoEntity> * @return MPJLambdaWrapper<PlayOrderInfoEntity>
*/ */
public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity) { public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity, String keyword) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询主表全部字段 // 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class); lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class);
@@ -743,6 +748,11 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, entity.getOrderType()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, entity.getOrderType());
} }
lambdaQueryWrapper.like(StringUtils.isNotEmpty(entity.getOrderNo()), PlayOrderInfoEntity::getOrderNo, entity.getOrderNo()); lambdaQueryWrapper.like(StringUtils.isNotEmpty(entity.getOrderNo()), PlayOrderInfoEntity::getOrderNo, entity.getOrderNo());
if (StringUtils.isNotBlank(keyword)) {
lambdaQueryWrapper.and(w -> w.like(PlayOrderInfoEntity::getOrderNo, keyword)
.or()
.like(PlayClerkUserInfoEntity::getNickname, keyword));
}
lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getCreatedTime); lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getCreatedTime);
return lambdaQueryWrapper; return lambdaQueryWrapper;
@@ -943,6 +953,14 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
notificationSender.sendOrderCancelMessageAsync(latest, refundReason); notificationSender.sendOrderCancelMessageAsync(latest, refundReason);
} }
@Override
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单信息缺失");
}
orderLifecycleService.revokeCompletedOrder(context);
}
@Override @Override
public PlayOrderInfoEntity queryByOrderNo(String orderNo) { public PlayOrderInfoEntity queryByOrderNo(String orderNo) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();

View File

@@ -109,9 +109,13 @@ public class PlayCommodityInfoController {
if (!jsonObject.containsKey(playClerkLevelInfoEntity.getId())) { if (!jsonObject.containsKey(playClerkLevelInfoEntity.getId())) {
throw new CustomException("请求参数错误"); throw new CustomException("请求参数错误");
} }
String rawPrice = jsonObject.getString(playClerkLevelInfoEntity.getId());
if (rawPrice == null || rawPrice.trim().isEmpty()) {
continue;
}
double price = 0.0; double price = 0.0;
try { try {
price = Double.parseDouble(jsonObject.getString(playClerkLevelInfoEntity.getId())); price = Double.parseDouble(rawPrice);
} catch (RuntimeException e) { } catch (RuntimeException e) {
throw new CustomException("请求参数错误,价格格式为空"); throw new CustomException("请求参数错误,价格格式为空");
} }

View File

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.PageBuilder;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
@@ -16,6 +17,7 @@ import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService; import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.modules.weichat.entity.gift.PlayClerkGiftReturnVo; import com.starry.admin.modules.weichat.entity.gift.PlayClerkGiftReturnVo;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import com.starry.common.utils.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -165,8 +167,11 @@ public class PlayGiftInfoServiceImpl extends ServiceImpl<PlayGiftInfoMapper, Pla
*/ */
@Override @Override
public IPage<PlayGiftInfoEntity> selectPlayGiftInfoByPage(PlayGiftInfoEntity playGiftInfo) { public IPage<PlayGiftInfoEntity> selectPlayGiftInfoByPage(PlayGiftInfoEntity playGiftInfo) {
Page<PlayGiftInfoEntity> page = new Page<>(1, 10); Page<PlayGiftInfoEntity> page = PageBuilder.build();
return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>()); LambdaQueryWrapper<PlayGiftInfoEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(playGiftInfo.getName()), PlayGiftInfoEntity::getName, playGiftInfo.getName());
wrapper.eq(StringUtils.isNotBlank(playGiftInfo.getState()), PlayGiftInfoEntity::getState, playGiftInfo.getState());
return this.baseMapper.selectPage(page, wrapper);
} }
/** /**

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.aspect.ClerkUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; 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.entity.*;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.clerk.service.*; import com.starry.admin.modules.clerk.service.*;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl; import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserReviewInfoServiceImpl; 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.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.PlayOrderCompleteVo; 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.admin.utils.SmsUtils;
import com.starry.common.redis.RedisCache; import com.starry.common.redis.RedisCache;
import com.starry.common.result.R; import com.starry.common.result.R;
import com.starry.common.result.TypedR;
import com.starry.common.utils.ConvertUtil; import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.StringUtils; import com.starry.common.utils.StringUtils;
import com.starry.common.utils.VerificationCodeUtils; import com.starry.common.utils.VerificationCodeUtils;
@@ -53,6 +58,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -120,6 +126,10 @@ public class WxClerkController {
private SmsUtils smsUtils; private SmsUtils smsUtils;
@Resource @Resource
private WxCustomMpService wxCustomMpService; 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.setReviewState("0");
entity.setDataContent(Collections.singletonList(vo.getNickname())); entity.setDataContent(Collections.singletonList(vo.getNickname()));
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ApiOperation(value = "更新性别", notes = "店员更新性别") @ApiOperation(value = "更新性别", notes = "店员更新性别")
@@ -283,7 +293,7 @@ public class WxClerkController {
entity.setReviewState("0"); entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex()))); entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex())));
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ApiOperation(value = "更新头像", notes = "店员更新头像") @ApiOperation(value = "更新头像", notes = "店员更新头像")
@@ -305,25 +315,138 @@ public class WxClerkController {
list.add(vo.getAvatar()); list.add(vo.getAvatar());
entity.setDataContent(list); entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ClerkUserLogin @ClerkUserLogin
@PostMapping("/user/updateAlbum") @PostMapping("/user/updateAlbum")
public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) { public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) {
PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo(); PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo();
// PlayClerkDataReviewInfoEntity entity = List<String> requested = vo.getAlbum() == null ? new ArrayList<>() : vo.getAlbum().stream()
// playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "2", "0"); .filter(StrUtil::isNotBlank)
// if (entity != null) { .map(String::trim)
// throw new CustomException("已有申请未审核"); .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(); PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();
entity.setClerkId(userInfo.getId()); entity.setClerkId(userInfo.getId());
entity.setDataType("2"); entity.setDataType("2");
entity.setReviewState("0"); entity.setReviewState("0");
entity.setDataContent(vo.getAlbum()); entity.setDataContent(new ArrayList<>(requestedSet));
playClerkDataReviewInfoService.create(entity); 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 @ClerkUserLogin
@@ -343,7 +466,7 @@ public class WxClerkController {
list.add(vo.getAudio()); list.add(vo.getAudio());
entity.setDataContent(list); entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ClerkUserLogin @ClerkUserLogin
@@ -394,10 +517,10 @@ public class WxClerkController {
* @return 店员列表 * @return 店员列表
*/ */
@PostMapping("/user/queryByPage") @PostMapping("/user/queryByPage")
public R queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) { public TypedR<IPage<PlayClerkUserInfoResultVo>> queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo, IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo,
customUserService.getLoginUserId()); 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)}) @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PlayClerkUserInfoResultVo.class)})
@GetMapping("/queryClerkDetailedById") @GetMapping("/queryClerkDetailedById")
public R queryClerkDetailedById(@RequestParam("id") String id) { 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(); String loginUserId = customUserService.getLoginUserId();
if (StringUtils.isNotEmpty(loginUserId)) { PlayClerkUserInfoResultVo vo = clerkUserInfoService.buildCustomerDetail(id,
vo.setFollowState(playCustomFollowInfoService.queryFollowState(loginUserId, vo.getId())); StringUtils.isNotEmpty(loginUserId) ? loginUserId : "");
}
// 服务项目
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
return R.ok(vo); return R.ok(vo);
} }

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.weichat.entity;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo; 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.ArrayList;
import java.util.List; import java.util.List;
import lombok.Data; import lombok.Data;
@@ -45,6 +46,11 @@ public class PlayClerkUserLoginResponseVo {
*/ */
private List<String> album = new ArrayList<>(); 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列表") @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>(); private List<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/** /**
* 个性签名 * 个性签名
*/ */

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderDetailsReturnVo {
private String orderNo; private String orderNo;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;

View File

@@ -16,14 +16,15 @@ public class PlayCustomOrderInfoQueryVo extends BasePageEntity {
private String id; private String id;
/** /**
* 订单状态【0:1:2:3:4】 0已下单 1已接单 2已开始 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单 1已接单 2已开始 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;
/** /**
* 订单类型【0充值订单1提现订单2普通订单】 * 订单类型(为空时默认排除充值/提现)
*/ */
private String orderType = "2"; private String orderType;
/** /**
* 下单类型0指定单1随机单。2打赏单 * 下单类型0指定单1随机单。2打赏单

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderListReturnVo {
private String orderNo; private String orderNo;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;

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

@@ -4,6 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
@@ -36,6 +40,7 @@ import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -59,6 +64,10 @@ public class AdminWithdrawalController {
private IEarningsBackfillLogService backfillLogService; private IEarningsBackfillLogService backfillLogService;
@Resource @Resource
private IPlayOrderInfoService orderInfoService; private IPlayOrderInfoService orderInfoService;
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IPlayCustomUserInfoService customUserInfoService;
@ApiOperation("分页查询提现请求") @ApiOperation("分页查询提现请求")
@PostMapping("/requests/listByPage") @PostMapping("/requests/listByPage")
@@ -112,7 +121,39 @@ public class AdminWithdrawalController {
.in(PlayOrderInfoEntity::getId, orderIds) .in(PlayOrderInfoEntity::getId, orderIds)
.list() .list()
.stream() .stream()
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it)); .collect(Collectors.toMap(PlayOrderInfoEntity::getId, Function.identity()));
Map<String, PlayClerkUserInfoEntity> clerkMap = Collections.emptyMap();
Map<String, PlayCustomUserInfoEntity> customerMap = Collections.emptyMap();
if (!orderMap.isEmpty()) {
List<String> clerkIds = orderMap.values().stream()
.map(PlayOrderInfoEntity::getAcceptBy)
.filter(clerkIdValue -> clerkIdValue != null && !clerkIdValue.isEmpty())
.distinct()
.collect(Collectors.toList());
if (!clerkIds.isEmpty()) {
clerkMap = clerkUserInfoService.lambdaQuery()
.eq(PlayClerkUserInfoEntity::getTenantId, tenantId)
.in(PlayClerkUserInfoEntity::getId, clerkIds)
.list()
.stream()
.collect(Collectors.toMap(PlayClerkUserInfoEntity::getId, Function.identity()));
}
List<String> customerIds = orderMap.values().stream()
.map(PlayOrderInfoEntity::getPurchaserBy)
.filter(customerIdValue -> customerIdValue != null && !customerIdValue.isEmpty())
.distinct()
.collect(Collectors.toList());
if (!customerIds.isEmpty()) {
customerMap = customUserInfoService.lambdaQuery()
.eq(PlayCustomUserInfoEntity::getTenantId, tenantId)
.in(PlayCustomUserInfoEntity::getId, customerIds)
.list()
.stream()
.collect(Collectors.toMap(PlayCustomUserInfoEntity::getId, Function.identity()));
}
}
List<ClerkEarningLineVo> vos = new ArrayList<>(lines.size()); List<ClerkEarningLineVo> vos = new ArrayList<>(lines.size());
for (EarningsLineEntity line : lines) { for (EarningsLineEntity line : lines) {
@@ -131,6 +172,22 @@ public class AdminWithdrawalController {
vo.setOrderNo(order.getOrderNo()); vo.setOrderNo(order.getOrderNo());
vo.setOrderStatus(order.getOrderStatus()); vo.setOrderStatus(order.getOrderStatus());
vo.setOrderEndTime(toLocalDateTime(order.getOrderEndTime())); vo.setOrderEndTime(toLocalDateTime(order.getOrderEndTime()));
String clerkId = order.getAcceptBy();
if (clerkId != null && !clerkId.isEmpty()) {
vo.setOrderClerkId(clerkId);
PlayClerkUserInfoEntity clerk = clerkMap.get(clerkId);
if (clerk != null) {
vo.setOrderClerkNickname(clerk.getNickname());
}
}
String customerId = order.getPurchaserBy();
if (customerId != null && !customerId.isEmpty()) {
vo.setOrderCustomerId(customerId);
PlayCustomUserInfoEntity customer = customerMap.get(customerId);
if (customer != null) {
vo.setOrderCustomerNickname(customer.getNickname());
}
}
} }
} }
vos.add(vo); vos.add(vo);

View File

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

View File

@@ -17,4 +17,8 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now); LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now);
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now); List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId);
} }

View File

@@ -1,7 +1,9 @@
package com.starry.admin.modules.withdraw.service.impl; 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.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.enums.EarningsType;
@@ -26,6 +28,9 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
@Override @Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) { public void createFromOrder(PlayOrderInfoEntity orderInfo) {
if (orderInfo == null || orderInfo.getAcceptBy() == null) return; if (orderInfo == null || orderInfo.getAcceptBy() == null) return;
if (OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode().equals(orderInfo.getOrderType())) {
return;
}
// amount from estimatedRevenue; fallback to orderMoney if null // amount from estimatedRevenue; fallback to orderMoney if null
BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue() BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue()
: (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO); : (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO);
@@ -90,4 +95,74 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
if (acc.compareTo(amount) < 0) return new ArrayList<>(); if (acc.compareTo(amount) < 0) return new ArrayList<>();
return picked; return picked;
} }
@Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
throw new IllegalArgumentException("创建冲销收益时参数缺失");
}
BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.abs();
if (normalized.compareTo(BigDecimal.ZERO) == 0) {
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.ADJUSTMENT);
line.setStatus(resolvedStatus);
line.setUnlockTime(resolvedUnlock);
this.save(line);
}
@Override
public BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId) {
if (StrUtil.hasBlank(orderId, clerkId)) {
return BigDecimal.ZERO;
}
List<EarningsLineEntity> lines = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId)
.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

@@ -27,6 +27,11 @@ public class ClerkEarningLineVo {
private String orderNo; private String orderNo;
private String orderStatus; private String orderStatus;
private String orderCustomerId;
private String orderCustomerNickname;
private String orderClerkId;
private String orderClerkNickname;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime orderEndTime; private LocalDateTime orderEndTime;

View File

@@ -14,8 +14,8 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver 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 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 username: root
password: apitest password: root
druid: druid:
enable: true enable: true
db-type: mysql db-type: mysql

View File

@@ -96,6 +96,10 @@ logging:
org.springframework.web.servlet.DispatcherServlet: debug org.springframework.web.servlet.DispatcherServlet: debug
org.springframework.security: debug org.springframework.security: debug
clerk:
media:
migration-enabled: false
jwt: jwt:
tokenHeader: X-Token #JWT存储的请求头 tokenHeader: X-Token #JWT存储的请求头
tokenHead: Bearer #JWT负载中拿到开头 tokenHead: Bearer #JWT负载中拿到开头
@@ -117,4 +121,3 @@ xl:
authCode: authCode:
# 登录验证码是否开启开发环境配置false方便测试 # 登录验证码是否开启开发环境配置false方便测试
enable: ${XL_LOGIN_AUTHCODE_ENABLE: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

@@ -8,6 +8,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
@@ -18,6 +22,7 @@ import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -40,6 +45,10 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
private IWithdrawalService withdrawalService; private IWithdrawalService withdrawalService;
@Autowired @Autowired
private IPlayOrderInfoService orderInfoService; private IPlayOrderInfoService orderInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private IPlayCustomUserInfoService customUserInfoService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final List<String> earningsToCleanup = new ArrayList<>(); private final List<String> earningsToCleanup = new ArrayList<>();
@@ -65,6 +74,14 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
@Test @Test
void auditReturnsEarningLinesWithOrderDetails() throws Exception { void auditReturnsEarningLinesWithOrderDetails() throws Exception {
ensureTenantContext(); ensureTenantContext();
ensureProfileFixtures();
PlayClerkUserInfoEntity expectedClerk = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
PlayCustomUserInfoEntity expectedCustomer = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
Assertions.assertThat(expectedClerk).as("default clerk fixture missing").isNotNull();
Assertions.assertThat(expectedCustomer).as("default customer fixture missing").isNotNull();
String expectedClerkNickname = expectedClerk.getNickname();
String expectedCustomerNickname = expectedCustomer.getNickname();
PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(2)); PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(2));
WithdrawalRequestEntity withdrawal = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("88.60")); WithdrawalRequestEntity withdrawal = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("88.60"));
@@ -83,6 +100,7 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
.andReturn(); .andReturn();
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data"); JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
boolean foundOrder = false; boolean foundOrder = false;
boolean foundMissing = false; boolean foundMissing = false;
for (JsonNode node : data) { for (JsonNode node : data) {
@@ -90,6 +108,10 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
if (order.getOrderNo().equals(orderNo)) { if (order.getOrderNo().equals(orderNo)) {
Assertions.assertThat(node.path("orderStatus").asText()).isEqualTo(order.getOrderStatus()); Assertions.assertThat(node.path("orderStatus").asText()).isEqualTo(order.getOrderStatus());
Assertions.assertThat(node.path("earningType").asText()).isEqualTo(EarningsType.ORDER.name()); Assertions.assertThat(node.path("earningType").asText()).isEqualTo(EarningsType.ORDER.name());
Assertions.assertThat(node.path("orderClerkId").asText()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
Assertions.assertThat(normalizeUtf8(node.path("orderClerkNickname").asText())).isEqualTo(expectedClerkNickname);
Assertions.assertThat(node.path("orderCustomerId").asText()).isEqualTo(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
Assertions.assertThat(normalizeUtf8(node.path("orderCustomerNickname").asText())).isEqualTo(expectedCustomerNickname);
foundOrder = true; foundOrder = true;
} }
if (node.path("orderNo").isNull()) { if (node.path("orderNo").isNull()) {
@@ -151,7 +173,11 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].orderNo").value(nullValue())) .andExpect(jsonPath("$.data[0].orderNo").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderId").value(orphanOrderId)); .andExpect(jsonPath("$.data[0].orderId").value(orphanOrderId))
.andExpect(jsonPath("$.data[0].orderClerkId").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderClerkNickname").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderCustomerId").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderCustomerNickname").value(nullValue()));
} }
@Test @Test
@@ -282,6 +308,72 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
return id; return id;
} }
private void ensureProfileFixtures() {
ensureTenantContext();
ensureClerkFixture();
ensureCustomerFixture();
}
private void ensureClerkFixture() {
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
if (clerk != null) {
return;
}
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setSysUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setOpenid(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID);
entity.setNickname("小测官");
entity.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
entity.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
entity.setFixingLevel("1");
entity.setSex("2");
entity.setPhone("13900000001");
entity.setWeiChatCode("apitest-clerk");
entity.setAvatar("https://example.com/avatar.png");
entity.setAccountBalance(BigDecimal.ZERO);
entity.setOnboardingState("1");
entity.setListingState("1");
entity.setDisplayState("1");
entity.setOnlineState("1");
entity.setRandomOrderState("1");
entity.setClerkState("1");
entity.setEntryTime(LocalDateTime.now());
entity.setToken("apitest-clerk-token");
entity.setDeleted(false);
clerkUserInfoService.save(entity);
}
private void ensureCustomerFixture() {
PlayCustomUserInfoEntity customer = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
if (customer != null) {
return;
}
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOpenid("openid-customer-apitest");
entity.setUnionid("unionid-customer-apitest");
entity.setNickname("测试顾客");
entity.setSex(1);
entity.setPhone("13700000002");
entity.setWeiChatCode("apitest-customer");
entity.setAccountBalance(new BigDecimal("200.00"));
entity.setAccumulatedRechargeAmount(new BigDecimal("200.00"));
entity.setAccumulatedConsumptionAmount(BigDecimal.ZERO);
entity.setAccountState("1");
entity.setSubscribeState("1");
entity.setPurchaseState("1");
entity.setMobilePhoneState("1");
Date now = new Date();
entity.setRegistrationTime(now);
entity.setLastLoginTime(now);
entity.setToken("apitest-customer-token");
entity.setDeleted(false);
customUserInfoService.save(entity);
}
private void ensureTenantContext() { private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
} }
@@ -289,4 +381,16 @@ class AdminWithdrawalControllerApiTest extends AbstractApiTest {
private Date toDate(LocalDateTime value) { private Date toDate(LocalDateTime value) {
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
} }
private String normalizeUtf8(String value) {
if (value == null) {
return null;
}
boolean convertible = value.chars().allMatch(ch -> ch <= 0xFF);
if (!convertible) {
return value;
}
byte[] bytes = value.getBytes(StandardCharsets.ISO_8859_1);
return new String(bytes, StandardCharsets.UTF_8);
}
} }

View File

@@ -0,0 +1,257 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasItem;
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.post;
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.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.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.shop.module.constant.GiftHistory;
import com.starry.admin.modules.shop.module.constant.GiftState;
import com.starry.admin.modules.shop.module.constant.GiftType;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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;
class BlindBoxPoolControllerApiTest extends AbstractApiTest {
private static final String TEST_BLIND_BOX_ID = "blindbox-admin-api";
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<Long> poolIdsToCleanup = new ArrayList<>();
private final List<String> giftIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
ensureTenantContext();
ensureBlindBoxConfig();
}
@AfterEach
void tearDown() {
ensureTenantContext();
if (!poolIdsToCleanup.isEmpty()) {
blindBoxPoolMapper.deleteBatchIds(poolIdsToCleanup);
poolIdsToCleanup.clear();
}
if (!giftIdsToCleanup.isEmpty()) {
giftInfoService.removeByIds(giftIdsToCleanup);
giftIdsToCleanup.clear();
}
}
@Test
// 测试用例:校验奖池管理 API 的新增、更新(禁用/修改权重时间库存)、删除以及礼物选项查询功能,
// 确保后台盲盒奖池的所有按钮都能正常调用并持久化。
void adminCanCreateUpdateToggleAndDeletePoolEntries() throws Exception {
PlayGiftInfoEntity freshlyAddedGift = seedGift("API盲盒新礼物");
giftIdsToCleanup.add(freshlyAddedGift.getId());
mockMvc.perform(get("/play/blind-box/pool/gifts")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.param("keyword", "盲盒新礼物"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[*].id", hasItem(freshlyAddedGift.getId())));
LocalDateTime now = LocalDateTime.now().withNano(0);
long configurableEntryId = createPoolEntryViaApi(
ApiTestDataSeeder.DEFAULT_GIFT_ID,
new BigDecimal("25.88"),
40,
8,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(1),
now.plusDays(5));
LocalDateTime updatedFrom = now.minusHours(2);
LocalDateTime updatedTo = now.plusDays(10);
ObjectNode updatePayload = objectMapper.createObjectNode();
updatePayload.put("blindBoxId", TEST_BLIND_BOX_ID);
updatePayload.put("rewardGiftId", ApiTestDataSeeder.DEFAULT_GIFT_ID);
updatePayload.put("rewardPrice", "35.66");
updatePayload.put("weight", 75);
updatePayload.put("remainingStock", 3);
updatePayload.put("status", BlindBoxPoolStatus.DISABLED.getCode());
updatePayload.put("validFrom", updatedFrom.format(DATE_TIME_FORMATTER));
updatePayload.put("validTo", updatedTo.format(DATE_TIME_FORMATTER));
mockMvc.perform(put("/play/blind-box/pool/" + configurableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(updatePayload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value(BlindBoxPoolStatus.DISABLED.getCode()))
.andExpect(jsonPath("$.data.weight").value(75))
.andExpect(jsonPath("$.data.remainingStock").value(3));
ensureTenantContext();
BlindBoxPoolEntity updated = blindBoxPoolMapper.selectById(configurableEntryId);
assertThat(updated).isNotNull();
assertThat(updated.getStatus()).isEqualTo(BlindBoxPoolStatus.DISABLED.getCode());
assertThat(updated.getWeight()).isEqualTo(75);
assertThat(updated.getRemainingStock()).isEqualTo(3);
assertThat(updated.getValidFrom()).isEqualTo(updatedFrom);
assertThat(updated.getValidTo()).isEqualTo(updatedTo);
long reusableEntryId = createPoolEntryViaApi(
freshlyAddedGift.getId(),
new BigDecimal("18.50"),
10,
1,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(2),
now.plusDays(3));
updateGiftState(freshlyAddedGift.getId(), GiftState.OFF_SHELF);
ObjectNode inactiveUpdate = objectMapper.createObjectNode();
inactiveUpdate.put("blindBoxId", TEST_BLIND_BOX_ID);
inactiveUpdate.put("rewardGiftId", freshlyAddedGift.getId());
inactiveUpdate.put("weight", 15);
inactiveUpdate.put("status", BlindBoxPoolStatus.ENABLED.getCode());
inactiveUpdate.putNull("remainingStock");
mockMvc.perform(put("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(inactiveUpdate.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.weight").value(15));
mockMvc.perform(delete("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
BlindBoxPoolEntity deleted = blindBoxPoolMapper.selectById(reusableEntryId);
assertThat(deleted).isNull();
poolIdsToCleanup.remove(reusableEntryId);
}
private long createPoolEntryViaApi(
String giftId,
BigDecimal rewardPrice,
int weight,
Integer remainingStock,
int status,
LocalDateTime validFrom,
LocalDateTime validTo) throws Exception {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("blindBoxId", TEST_BLIND_BOX_ID);
payload.put("rewardGiftId", giftId);
payload.put("rewardPrice", rewardPrice.setScale(2, RoundingMode.HALF_UP).toPlainString());
payload.put("weight", weight);
if (remainingStock != null) {
payload.put("remainingStock", remainingStock);
} else {
payload.putNull("remainingStock");
}
payload.put("status", status);
payload.put("validFrom", validFrom.format(DATE_TIME_FORMATTER));
payload.put("validTo", validTo.format(DATE_TIME_FORMATTER));
MvcResult result = mockMvc.perform(post("/play/blind-box/pool")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").isNumber())
.andReturn();
JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString());
long id = response.path("data").path("id").asLong();
poolIdsToCleanup.add(id);
return id;
}
private void ensureBlindBoxConfig() {
BlindBoxConfigEntity existing = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (existing != null) {
return;
}
BlindBoxConfigEntity entity = new BlindBoxConfigEntity();
entity.setId(TEST_BLIND_BOX_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setName("API盲盒Admin");
entity.setPrice(new BigDecimal("19.90"));
entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode());
blindBoxConfigService.save(entity);
}
private PlayGiftInfoEntity seedGift(String name) {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-admin-" + IdUtils.getUuid());
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setName(name);
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/assets/" + gift.getId() + ".png");
gift.setPrice(new BigDecimal("58.80"));
gift.setUnit("CNY");
gift.setState(GiftState.ACTIVE.getCode());
gift.setListingTime(LocalDateTime.now().minusDays(1));
gift.setRemark("Seeded for blind box pool admin test");
giftInfoService.save(gift);
return gift;
}
private void updateGiftState(String giftId, GiftState state) {
ensureTenantContext();
PlayGiftInfoEntity gift = giftInfoService.getById(giftId);
if (gift != null) {
gift.setState(state.getCode());
giftInfoService.updateById(gift);
}
}
protected void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
}

View File

@@ -7,6 +7,7 @@ import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper; import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
@@ -34,6 +35,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final String TEST_BLIND_BOX_ID = "blindbox-apitest"; private static final String TEST_BLIND_BOX_ID = "blindbox-apitest";
private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID; private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID;
private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary"; private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary";
private static final String MIXED_GIFT_A_ID = "gift-blindbox-mixed-a";
private static final String MIXED_GIFT_B_ID = "gift-blindbox-mixed-b";
private static final String MIXED_GIFT_C_ID = "gift-blindbox-mixed-c";
private static final String MIXED_GIFT_D_ID = "gift-blindbox-mixed-d";
private static final String MIXED_GIFT_E_ID = "gift-blindbox-mixed-e";
private static final int DRAW_ATTEMPT_COUNT = 1_000; private static final int DRAW_ATTEMPT_COUNT = 1_000;
private static final int PRIMARY_WEIGHT = 80; private static final int PRIMARY_WEIGHT = 80;
private static final int SECONDARY_WEIGHT = 20; private static final int SECONDARY_WEIGHT = 20;
@@ -41,6 +47,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final double PRIMARY_RATIO_MAX = 0.88; private static final double PRIMARY_RATIO_MAX = 0.88;
private static final double SECONDARY_RATIO_MIN = 0.12; private static final double SECONDARY_RATIO_MIN = 0.12;
private static final double SECONDARY_RATIO_MAX = 0.32; private static final double SECONDARY_RATIO_MAX = 0.32;
private static final int MIXED_TOTAL_DRAWS = 400;
private static final int MIXED_GIFT_A_STOCK = 10;
private static final int MIXED_GIFT_B_STOCK = 5;
private static final int MIXED_GIFT_C_STOCK = 0;
private static final int MIXED_GIFT_E_STOCK = 2;
@Autowired @Autowired
private BlindBoxService blindBoxService; private BlindBoxService blindBoxService;
@@ -112,6 +123,66 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
purgePool(); purgePool();
} }
@Test
// 测试用例:混合 5 种礼物(部分限量、部分不限量),验证限量礼物被抽满后不再出现,
// 不限量礼物可继续抽取,且库存为 0 的礼物永远不会返回。
void blindBoxDrawHandlesMixedInventory() {
ensureTenantContext();
ensureBlindBoxConfig();
ensureMixedGifts();
resetCustomerBalance();
purgeRewards();
purgePool();
insertPoolEntry(MIXED_GIFT_A_ID, 40, MIXED_GIFT_A_STOCK);
insertPoolEntry(MIXED_GIFT_B_ID, 25, MIXED_GIFT_B_STOCK);
insertPoolEntry(MIXED_GIFT_C_ID, 15, MIXED_GIFT_C_STOCK);
insertPoolEntry(MIXED_GIFT_D_ID, 10, null);
insertPoolEntry(MIXED_GIFT_E_ID, 10, MIXED_GIFT_E_STOCK);
Map<String, Integer> frequency = new HashMap<>();
for (int i = 0; i < MIXED_TOTAL_DRAWS; i++) {
BlindBoxRewardEntity reward = blindBoxService.drawReward(
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"mixed-order-" + i,
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID,
TEST_BLIND_BOX_ID,
"mixed-seed-" + i);
frequency.merge(reward.getRewardGiftId(), 1, Integer::sum);
}
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_C_ID, 0))
.as("库存为 0 的礼物永远不应被抽中")
.isEqualTo(0);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_A_ID, 0))
.as("限量礼物 A 应被精确抽完")
.isEqualTo(MIXED_GIFT_A_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_B_ID, 0))
.as("限量礼物 B 应被精确抽完")
.isEqualTo(MIXED_GIFT_B_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_E_ID, 0))
.as("限量礼物 E 应被精确抽完")
.isEqualTo(MIXED_GIFT_E_STOCK);
int unlimitedCount = frequency.getOrDefault(MIXED_GIFT_D_ID, 0);
int finiteTotal = MIXED_GIFT_A_STOCK + MIXED_GIFT_B_STOCK + MIXED_GIFT_E_STOCK;
Assertions.assertThat(unlimitedCount)
.as("不限量礼物承担剩余抽奖次数")
.isEqualTo(MIXED_TOTAL_DRAWS - finiteTotal)
.isGreaterThan(0);
// 抽完后,奖池中仅剩不限量礼物
java.util.List<BlindBoxCandidate> remaining = blindBoxPoolMapper.listActiveEntries(
ApiTestDataSeeder.DEFAULT_TENANT_ID, TEST_BLIND_BOX_ID, LocalDateTime.now());
Assertions.assertThat(remaining)
.as("只剩不限量礼物可用")
.hasSize(1);
Assertions.assertThat(remaining.get(0).getRewardGiftId()).isEqualTo(MIXED_GIFT_D_ID);
purgeRewards();
purgePool();
}
private void ensureBlindBoxConfig() { private void ensureBlindBoxConfig() {
BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID); BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (config != null) { if (config != null) {
@@ -129,46 +200,81 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
} }
private void ensureSecondaryGift() { private void ensureSecondaryGift() {
PlayGiftInfoEntity existing = findGift(SECONDARY_GIFT_ID); ensureGift(
if (existing != null) { SECONDARY_GIFT_ID,
return; "API盲盒奖励",
} new BigDecimal("9.99"),
PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); "https://example.com/apitest/blindbox.png",
entity.setId(SECONDARY_GIFT_ID); "Seeded secondary gift for blind box tests");
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName("API盲盒奖励");
entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/blindbox.png");
entity.setPrice(new BigDecimal("9.99"));
entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded secondary gift for blind box tests");
giftInfoService.save(entity);
} }
private void ensurePrimaryGift() { private void ensurePrimaryGift() {
PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID); ensureGift(
PRIMARY_GIFT_ID,
ApiTestDataSeeder.DEFAULT_GIFT_NAME,
new BigDecimal("15.00"),
"https://example.com/apitest/gift-basic.png",
"Seeded default gift for blind box tests");
}
private void ensureMixedGifts() {
ensureGift(
MIXED_GIFT_A_ID,
"API盲盒混合A",
new BigDecimal("11.11"),
"https://example.com/apitest/mixed-a.png",
"Mixed blind box gift A");
ensureGift(
MIXED_GIFT_B_ID,
"API盲盒混合B",
new BigDecimal("22.22"),
"https://example.com/apitest/mixed-b.png",
"Mixed blind box gift B");
ensureGift(
MIXED_GIFT_C_ID,
"API盲盒混合C",
new BigDecimal("33.33"),
"https://example.com/apitest/mixed-c.png",
"Mixed blind box gift C");
ensureGift(
MIXED_GIFT_D_ID,
"API盲盒混合D",
new BigDecimal("44.44"),
"https://example.com/apitest/mixed-d.png",
"Mixed blind box gift D");
ensureGift(
MIXED_GIFT_E_ID,
"API盲盒混合E",
new BigDecimal("55.55"),
"https://example.com/apitest/mixed-e.png",
"Mixed blind box gift E");
}
private void ensureGift(String giftId, String name, BigDecimal price, String imageUrl, String remark) {
PlayGiftInfoEntity existing = findGift(giftId);
if (existing != null) { if (existing != null) {
return; return;
} }
PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(PRIMARY_GIFT_ID); entity.setId(giftId);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode()); entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME); entity.setName(name);
entity.setType(GiftType.NORMAL.getCode()); entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/gift-basic.png"); entity.setUrl(imageUrl);
entity.setPrice(new BigDecimal("15.00")); entity.setPrice(price);
entity.setUnit("CNY"); entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode()); entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now()); entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded default gift for blind box tests"); entity.setRemark(remark);
giftInfoService.save(entity); giftInfoService.save(entity);
} }
private void insertPoolEntry(String giftId, int weight) { private void insertPoolEntry(String giftId, int weight) {
insertPoolEntry(giftId, weight, null);
}
private void insertPoolEntry(String giftId, int weight, Integer remainingStock) {
PlayGiftInfoEntity gift = findGift(giftId); PlayGiftInfoEntity gift = findGift(giftId);
if (gift == null) { if (gift == null) {
throw new IllegalStateException("Expected gift to be seeded: " + giftId); throw new IllegalStateException("Expected gift to be seeded: " + giftId);
@@ -179,7 +285,7 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
entry.setRewardGiftId(giftId); entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice()); entry.setRewardPrice(gift.getPrice());
entry.setWeight(weight); entry.setWeight(weight);
entry.setRemainingStock(null); entry.setRemainingStock(remainingStock);
entry.setValidFrom(null); entry.setValidFrom(null);
entry.setValidTo(null); entry.setValidTo(null);
entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode()); entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode());

View File

@@ -17,8 +17,12 @@ import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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> levelIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = 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 @AfterEach
void tearDown() { void tearDown() {
@@ -83,9 +94,10 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
} }
assertThat(orderedIds).contains(lowOrderClerkId, highOrderClerkId); assertThat(orderedIds).contains(lowOrderClerkId, highOrderClerkId);
assertThat(orderedIds.indexOf(lowOrderClerkId)) assertThat(orderedIds.indexOf(highOrderClerkId))
.withFailMessage("Unexpected order for token %s: %s", filterToken, orderedIds) .withFailMessage("Online clerk should appear before offline regardless of level. token=%s list=%s",
.isLessThan(orderedIds.indexOf(highOrderClerkId)); filterToken, orderedIds)
.isLessThan(orderedIds.indexOf(lowOrderClerkId));
} }
@Test @Test
@@ -319,4 +331,178 @@ class PlayClerkUserInfoApiTest extends AbstractApiTest {
clerkIdsToCleanup.add(clerkId); clerkIdsToCleanup.add(clerkId);
return 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

@@ -221,6 +221,39 @@ class PlayCommodityInfoApiTest extends WxCustomOrderApiTestSupport {
assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50")); assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50"));
} }
@Test
// 测试用例调用价格更新接口时若某个等级价格传入null接口应忽略该列并保持原价确保支持分步维护价格。
void updateInfoSkipsNullLevelPrices() throws Exception {
ensureTenantContext();
PlayCommodityAndLevelInfoEntity before = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.one();
assertThat(before).as("种子数据应具备默认等级价格").isNotNull();
ObjectNode payload = objectMapper.createObjectNode();
payload.put("id", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
payload.putNull(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
mockMvc.perform(post("/shop/commodity/updateInfo")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayCommodityAndLevelInfoEntity after = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.one();
assertThat(after).isNotNull();
assertThat(after.getPrice()).as("空价格不应覆盖原值").isEqualByComparingTo(before.getPrice());
}
@Test @Test
// 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1调整为10分钟验证更新后查询返回新的配置。 // 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1调整为10分钟验证更新后查询返回新的配置。
void updateEndpointSwitchesAutomaticSettlement() throws Exception { void updateEndpointSwitchesAutomaticSettlement() throws Exception {

View File

@@ -1,6 +1,7 @@
package com.starry.admin.api; package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -10,8 +11,15 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
@@ -38,12 +46,28 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
@Autowired @Autowired
private IPlayOrderInfoService orderInfoService; private IPlayOrderInfoService orderInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IPlayOrderRefundInfoService orderRefundInfoService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final List<String> orderIdsToCleanup = new ArrayList<>(); private final List<String> orderIdsToCleanup = new ArrayList<>();
private final List<String> earningsLineIdsToCleanup = new ArrayList<>();
private final List<String> refundIdsToCleanup = new ArrayList<>();
@AfterEach @AfterEach
void tearDown() { void tearDown() {
ensureTenantContext(); ensureTenantContext();
if (!earningsLineIdsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsLineIdsToCleanup);
earningsLineIdsToCleanup.clear();
}
if (!refundIdsToCleanup.isEmpty()) {
orderRefundInfoService.removeByIds(refundIdsToCleanup);
refundIdsToCleanup.clear();
}
if (!orderIdsToCleanup.isEmpty()) { if (!orderIdsToCleanup.isEmpty()) {
orderInfoService.removeByIds(orderIdsToCleanup); orderInfoService.removeByIds(orderIdsToCleanup);
orderIdsToCleanup.clear(); orderIdsToCleanup.clear();
@@ -183,6 +207,512 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
assertFilterMatches(combinedPayload, matching.getId()); assertFilterMatches(combinedPayload, matching.getId());
} }
@Test
void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
ensureTenantContext();
String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().plusHours(2);
PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
ObjectNode orderNoPayload = baseQuery();
orderNoPayload.put("keyword", orderByNo.getOrderNo());
assertFilterMatches(orderNoPayload, orderByNo.getId());
ObjectNode clerkKeywordPayload = baseQuery();
clerkKeywordPayload.put("keyword", "小测官");
clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15)));
clerkKeywordPayload.put("placeType", "1");
RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
JsonNode clerkRecords = clerkResponse.records;
assertThat(clerkRecords.size()).isGreaterThan(0);
List<String> ids = new ArrayList<>();
clerkRecords.forEach(node -> ids.add(node.path("id").asText()));
assertThat(ids).contains(orderByClerk.getId());
}
@Test
void listByPage_keywordRespectsAdditionalFilters() throws Exception {
ensureTenantContext();
String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().plusHours(3);
PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> {
order.setOrderStatus("3");
order.setPlaceType("0");
});
persistOrder(marker, "random", reference.minusMinutes(20), order -> {
order.setOrderStatus("3");
order.setPlaceType("1");
});
ObjectNode keywordAndFilterPayload = baseQuery();
keywordAndFilterPayload.put("keyword", "小测官");
keywordAndFilterPayload.put("placeType", "0");
keywordAndFilterPayload.set("purchaserTime", range(reference.minusMinutes(2), reference.plusMinutes(2)));
RecordsResponse filteredResponse = executeList(keywordAndFilterPayload);
JsonNode records = filteredResponse.records;
assertThat(records.size()).isEqualTo(1);
assertThat(records.get(0).path("id").asText()).isEqualTo(assignedOrder.getId());
}
@Test
void revokeCompletedOrder_keepEarningsIgnoresLockedLines() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "keep", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
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("deductClerkEarnings", false);
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())
.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_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"));
});
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("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("20.00"));
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())
.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();
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
assertThat(lines).hasSize(2);
EarningsLineEntity negativeLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
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", 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)
.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.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("80.00"));
refundIdsToCleanup.add(refundInfo.getId());
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("-60.00"));
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();
LocalDateTime reference = LocalDateTime.now().minusMinutes(10);
PlayOrderInfoEntity giftOrder = persistOrder("RVK", "gift", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setOrderType(OrderConstant.OrderType.GIFT.getCode());
entity.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
});
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", giftOrder.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "gift revoke");
payload.put("deductClerkEarnings", false);
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 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( private PlayOrderInfoEntity persistOrder(
String marker, String marker,
String token, String token,
@@ -272,6 +802,36 @@ class PlayOrderInfoControllerApiTest extends AbstractApiTest {
return array; return array;
} }
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);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setOrderId(orderId);
entity.setAmount(amount);
entity.setStatus(status);
entity.setEarningType(EarningsType.ORDER);
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;
}
private void assertFilterMatches(ObjectNode payload, String expectedOrderId) throws Exception { private void assertFilterMatches(ObjectNode payload, String expectedOrderId) throws Exception {
RecordsResponse response = executeList(payload); RecordsResponse response = executeList(payload);
JsonNode records = response.records; JsonNode records = response.records;

View File

@@ -4,25 +4,49 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService; import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.shop.module.constant.GiftHistory;
import com.starry.admin.modules.shop.module.constant.GiftState;
import com.starry.admin.modules.shop.module.constant.GiftType;
import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants; import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import org.assertj.core.api.Assertions; import org.assertj.core.api.Assertions;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport { class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
@Autowired @Autowired
private BlindBoxConfigService blindBoxConfigService; private BlindBoxConfigService blindBoxConfigService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxRewardMapper blindBoxRewardMapper;
@Autowired
private IPlayClerkGiftInfoService clerkGiftInfoService;
@Test @Test
void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception { void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
@@ -73,4 +97,116 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
CustomSecurityContextHolder.remove(); CustomSecurityContextHolder.remove();
} }
} }
@Test
void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception {
String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6);
String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6);
Long poolId = null;
String rewardId = null;
try {
ensureTenantContext();
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId(configId);
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
config.setName("下架礼物测试盲盒");
config.setPrice(new BigDecimal("19.90"));
config.setStatus(1);
blindBoxConfigService.save(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId(giftId);
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setName("盲盒下架礼物");
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setState(GiftState.ACTIVE.getCode());
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/apitest/blindbox-off.png");
gift.setPrice(new BigDecimal("9.99"));
giftInfoService.save(gift);
BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entry.setBlindBoxId(configId);
entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice());
entry.setWeight(100);
entry.setRemainingStock(1);
entry.setStatus(1);
entry.setValidFrom(LocalDateTime.now().minusDays(1));
entry.setValidTo(LocalDateTime.now().plusDays(1));
blindBoxPoolMapper.insert(entry);
poolId = entry.getId();
gift.setState(GiftState.OFF_SHELF.getCode());
giftInfoService.updateById(gift);
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String payload = objectMapper.createObjectNode()
.put("blindBoxId", configId)
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.put("weiChatCode", "apitest-customer-wx")
.toString();
MvcResult purchaseResult = mockMvc.perform(post("/wx/blind-box/order/purchase")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.reward.rewardId").isString())
.andReturn();
JsonNode rewardNode = objectMapper.readTree(purchaseResult.getResponse().getContentAsString())
.path("data").path("reward");
rewardId = rewardNode.path("rewardId").asText();
Assertions.assertThat(rewardId).isNotBlank();
SoftAssertions softly = new SoftAssertions();
softly.assertThat(rewardNode.path("rewardGiftId").asText()).isEqualTo(giftId);
softly.assertThat(rewardNode.path("status").asText()).isEqualTo("UNUSED");
softly.assertAll();
PlayClerkGiftInfoEntity before = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long beforeCount = before != null && before.getGiffNumber() != null ? before.getGiffNumber() : 0L;
mockMvc.perform(post("/wx/blind-box/reward/" + rewardId + "/dispatch")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.createObjectNode()
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value("USED"));
ensureTenantContext();
BlindBoxRewardEntity storedReward = blindBoxRewardMapper.selectById(rewardId);
Assertions.assertThat(storedReward).isNotNull();
Assertions.assertThat(storedReward.getStatus()).isEqualTo("USED");
Assertions.assertThat(storedReward.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
PlayClerkGiftInfoEntity after = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long afterCount = after != null && after.getGiffNumber() != null ? after.getGiffNumber() : 0L;
Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
} finally {
ensureTenantContext();
if (Objects.nonNull(poolId)) {
blindBoxPoolMapper.deleteById(poolId);
}
blindBoxConfigService.removeById(configId);
giftInfoService.removeById(giftId);
if (rewardId != null) {
blindBoxRewardMapper.deleteById(rewardId);
}
CustomSecurityContextHolder.remove();
}
}
} }

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

@@ -0,0 +1,594 @@
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;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService;
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
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;
class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
@Autowired
private IPlayOrderEvaluateInfoService playOrderEvaluateInfoService;
@Autowired
private IPlayOrderComplaintInfoService playOrderComplaintInfoService;
private final List<String> orderIdsToCleanup = new ArrayList<>();
private final List<String> evalIdsToCleanup = new ArrayList<>();
private final List<String> complaintIdsToCleanup = new ArrayList<>();
@AfterEach
void cleanUpOrders() {
if (!orderIdsToCleanup.isEmpty()) {
playOrderInfoService.removeByIds(orderIdsToCleanup);
orderIdsToCleanup.clear();
}
if (!evalIdsToCleanup.isEmpty()) {
playOrderEvaluateInfoService.removeByIds(evalIdsToCleanup);
evalIdsToCleanup.clear();
}
if (!complaintIdsToCleanup.isEmpty()) {
playOrderComplaintInfoService.removeByIds(complaintIdsToCleanup);
complaintIdsToCleanup.clear();
}
}
@Test
void queryByPageFiltersRevokedOrdersAndDetailShowsReason() 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 = "revoked-flow-" + 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).as("Order with remark %s should exist", remark).isNotNull();
String orderId = createdOrder.getId();
ensureTenantContext();
playOrderInfoService.lambdaUpdate()
.set(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.NORMAL.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);
String revokeReason = "auto-revoke-" + IdUtils.getUuid();
String revokePayload = "{" +
"\"orderId\":\"" + orderId + "\"," +
"\"refundToCustomer\":false," +
"\"refundReason\":\"" + revokeReason + "\"," +
"\"deductClerkEarnings\":false" +
"}";
mockMvc.perform(post("/order/order/revokeCompleted")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(revokePayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ObjectNode filterPayload = basePayload(1, 10);
filterPayload.put("orderStatus", OrderConstant.OrderStatus.REVOKED.getCode());
JsonNode listRoot = executeOrderQuery(customerToken, filterPayload);
JsonNode dataNode = listRoot.path("data");
JsonNode records = dataNode.isArray() ? dataNode : dataNode.path("records");
assertThat(records.isArray()).as("List response should contain records array").isTrue();
assertThat(records.size()).as("Should return at least one revoked order").isGreaterThan(0);
boolean found = false;
for (JsonNode node : records) {
assertThat(node.path("orderStatus").asText()).isEqualTo("5");
if (orderId.equals(node.path("id").asText())) {
found = true;
}
}
assertThat(found).as("Revoked order should be present in filter result").isTrue();
MvcResult detailResult = mockMvc.perform(get("/wx/custom/order/queryById")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.param("id", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode detailRoot = objectMapper.readTree(detailResult.getResponse().getContentAsString());
JsonNode detail = detailRoot.path("data");
assertThat(detail.path("orderStatus").asText()).isEqualTo("5");
assertThat(detail.path("refundReason").asText()).isEqualTo(revokeReason);
} finally {
CustomSecurityContextHolder.remove();
}
}
@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);
try {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String rechargeRemark = "recharge-like-" + IdUtils.getUuid();
LocalDateTime now = LocalDateTime.now().minusMinutes(20);
PlayOrderInfoEntity rechargeOrder = persistOrder(now, order -> {
order.setRemark(rechargeRemark);
order.setOrderType(OrderConstant.OrderType.RECHARGE.getCode());
});
String giftRemark = "gift-like-" + IdUtils.getUuid();
PlayOrderInfoEntity giftOrder = persistOrder(now.plusMinutes(5), order -> {
order.setRemark(giftRemark);
order.setOrderType(OrderConstant.OrderType.GIFT.getCode());
});
ObjectNode defaultPayload = basePayload(1, 20);
JsonNode defaultRecords = queryOrders(customerToken, defaultPayload);
assertThat(defaultRecords.size()).isGreaterThan(0);
assertThat(defaultRecords).noneMatch(node -> rechargeOrder.getId().equals(node.path("id").asText()));
ObjectNode explicitPayload = basePayload(1, 20);
explicitPayload.put("orderType", OrderConstant.OrderType.GIFT.getCode());
JsonNode filteredRecords = queryOrders(customerToken, explicitPayload);
assertThat(filteredRecords)
.anyMatch(node -> giftOrder.getId().equals(node.path("id").asText()));
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().minusMinutes(30);
PlayOrderInfoEntity own = persistOrder(base, order -> order.setOrderNo("OWN-" + IdUtils.getUuid().substring(0, 6)));
PlayOrderInfoEntity foreign = persistOrder(base.plusMinutes(5), order -> {
order.setPurchaserBy("other-customer");
order.setOrderNo("FOREIGN-" + IdUtils.getUuid().substring(0, 6));
});
ObjectNode payload = basePayload(1, 20);
JsonNode records = queryOrders(token, payload);
List<String> ids = new ArrayList<>();
records.forEach(node -> ids.add(node.path("id").asText()));
assertThat(ids).contains(own.getId());
assertThat(ids).doesNotContain(foreign.getId());
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageSupportsPagingMeta() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().plusHours(2);
String pageMarker = "PAGE-" + IdUtils.getUuid().substring(0, 4);
String pageGroup = "group-" + pageMarker;
PlayOrderInfoEntity first = persistOrder(base, order -> {
order.setOrderNo(pageMarker + "A");
order.setGroupId(pageGroup);
});
PlayOrderInfoEntity second = persistOrder(base.plusMinutes(2), order -> {
order.setOrderNo(pageMarker + "B");
order.setGroupId(pageGroup);
});
PlayOrderInfoEntity third = persistOrder(base.plusMinutes(4), order -> {
order.setOrderNo(pageMarker + "C");
order.setGroupId(pageGroup);
});
ArrayNode purchaserWindow = range(base.minusMinutes(1), base.plusMinutes(5));
ObjectNode pageOne = basePayload(1, 2);
pageOne.set("purchaserTime", purchaserWindow);
pageOne.put("orderNo", pageMarker);
pageOne.put("groupId", pageGroup);
JsonNode rootPageOne = executeOrderQuery(token, pageOne);
JsonNode recordsOne = recordsFromRoot(rootPageOne);
assertThat(recordsOne.size()).isEqualTo(2);
assertThat(rootPageOne.path("pageInfo").path("pageSize").asInt()).isEqualTo(2);
assertThat(rootPageOne.path("pageInfo").path("currentPage").asInt()).isEqualTo(1);
ObjectNode pageTwo = basePayload(2, 2);
pageTwo.set("purchaserTime", purchaserWindow);
pageTwo.put("orderNo", pageMarker);
pageTwo.put("groupId", pageGroup);
JsonNode rootPageTwo = executeOrderQuery(token, pageTwo);
JsonNode recordsTwo = recordsFromRoot(rootPageTwo);
assertThat(recordsTwo.size()).isGreaterThan(0);
assertThat(rootPageTwo.path("pageInfo").path("pageSize").asInt()).isEqualTo(2);
assertThat(rootPageTwo.path("pageInfo").path("currentPage").asInt()).isEqualTo(2);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageFiltersByOrderStatus() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
PlayOrderInfoEntity pending = persistOrder(LocalDateTime.now().minusMinutes(50),
order -> order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode()));
PlayOrderInfoEntity completed = persistOrder(LocalDateTime.now().minusMinutes(40),
order -> order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode()));
PlayOrderInfoEntity revoked = persistOrder(LocalDateTime.now().minusMinutes(30),
order -> order.setOrderStatus(OrderConstant.OrderStatus.REVOKED.getCode()));
ObjectNode payload = basePayload(1, 10);
payload.put("orderStatus", OrderConstant.OrderStatus.REVOKED.getCode());
JsonNode records = queryOrders(token, payload);
assertThat(records.size()).isGreaterThan(0);
records.forEach(node -> assertThat(node.path("orderStatus").asText())
.isEqualTo(OrderConstant.OrderStatus.REVOKED.getCode()));
assertThat(findById(records, revoked.getId())).isNotNull();
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageFiltersByPlaceTypeAndCompositeCriteria() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
LocalDateTime base = LocalDateTime.now().minusMinutes(90);
PlayOrderInfoEntity target = persistOrder(base, order -> {
order.setOrderStatus(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
order.setPlaceType(OrderConstant.PlaceType.SPECIFIED.getCode());
order.setOrderNo("FOCUS-" + IdUtils.getUuid().substring(0, 4));
order.setUseCoupon("1");
order.setBackendEntry("1");
order.setFirstOrder("1");
order.setGroupId("group-focus");
order.setSex("1");
order.setPurchaserTime(base.plusMinutes(5));
order.setAcceptTime(base.plusMinutes(10));
order.setOrderEndTime(base.plusMinutes(50));
});
persistOrder(base.plusMinutes(5), order -> {
order.setOrderStatus(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
order.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
order.setUseCoupon("0");
order.setBackendEntry("0");
order.setFirstOrder("0");
order.setGroupId("group-noise");
order.setSex("2");
});
ObjectNode payload = basePayload(1, 10);
payload.put("placeType", OrderConstant.PlaceType.SPECIFIED.getCode());
payload.put("orderNo", target.getOrderNo().substring(0, 6));
payload.put("useCoupon", "1");
payload.put("backendEntry", "1");
payload.put("firstOrder", "1");
payload.put("groupId", "group-focus");
payload.put("sex", "1");
payload.set("purchaserTime", range(target.getPurchaserTime().minusMinutes(1), target.getPurchaserTime().plusMinutes(1)));
payload.set("acceptTime", range(target.getAcceptTime().minusMinutes(1), target.getAcceptTime().plusMinutes(1)));
payload.set("endOrderTime", range(target.getOrderEndTime().minusMinutes(1), target.getOrderEndTime().plusMinutes(1)));
JsonNode records = queryOrders(token, payload);
assertThat(records.size()).isGreaterThan(0);
JsonNode targetNode = findById(records, target.getId());
assertThat(targetNode).isNotNull();
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test
void queryByPageMarksEvaluateAndComplaintFlags() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
try {
String token = ensureCustomerToken();
PlayOrderInfoEntity evaluated = persistOrder(LocalDateTime.now().minusMinutes(10),
order -> order.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4)));
PlayOrderInfoEntity complained = persistOrder(LocalDateTime.now().minusMinutes(8),
order -> order.setOrderNo("COMP-" + IdUtils.getUuid().substring(0, 4)));
markEvaluated(evaluated.getId());
markComplained(complained.getId());
ObjectNode payload = basePayload(1, 20);
JsonNode records = queryOrders(token, payload);
String evalFlag = findById(records, evaluated.getId()).path("evaluate").asText();
String complaintFlag = findById(records, complained.getId()).path("complaint").asText();
assertThat(evalFlag).isEqualTo("1");
assertThat(complaintFlag).isEqualTo("1");
} finally {
CustomSecurityContextHolder.remove();
}
}
private void placeRandomOrder(String remark, String customerToken) throws Exception {
String payload = "{" +
"\"sex\":\"2\"," +
"\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
"\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
"\"commodityQuantity\":1," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"excludeHistory\":\"0\"," +
"\"couponIds\":[]," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/random")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value("下单成功"));
}
private PlayOrderInfoEntity persistOrder(LocalDateTime baseTime, java.util.function.Consumer<PlayOrderInfoEntity> customizer) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-" + IdUtils.getUuid());
order.setOrderNo("WXQ-" + IdUtils.getUuid().substring(0, 8));
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode());
order.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
order.setPlaceType(OrderConstant.PlaceType.SPECIFIED.getCode());
order.setRewardType("0");
order.setFirstOrder("0");
order.setRefundType("0");
order.setRefundAmount(BigDecimal.ZERO);
order.setOrderMoney(new BigDecimal("99.00"));
order.setFinalAmount(new BigDecimal("99.00"));
order.setDiscountAmount(BigDecimal.ZERO);
order.setEstimatedRevenue(new BigDecimal("40.00"));
order.setEstimatedRevenueRatio(40);
order.setUseCoupon("0");
order.setBackendEntry("0");
order.setCouponIds(java.util.Collections.emptyList());
order.setPaymentSource("balance");
order.setPayMethod("0");
order.setPayState("1");
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setPurchaserTime(baseTime);
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
order.setAcceptTime(baseTime.plusMinutes(5));
order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
order.setOrderStartTime(baseTime.plusMinutes(10));
order.setOrderEndTime(baseTime.plusMinutes(40));
order.setOrdersExpiredState("0");
order.setOrderSettlementState("0");
order.setSex("2");
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setCommodityType("1");
order.setCommodityPrice(new BigDecimal("99.00"));
order.setCommodityName("Weixin Order");
order.setServiceDuration("60min");
order.setCommodityNumber("1");
order.setRemark("auto");
order.setBackendRemark("auto");
Date createdDate = toDate(baseTime);
order.setCreatedTime(createdDate);
order.setUpdatedTime(createdDate);
order.setCreatedBy("wx-test");
order.setUpdatedBy("wx-test");
customizer.accept(order);
ensureTenantContext();
playOrderInfoService.save(order);
orderIdsToCleanup.add(order.getId());
return order;
}
private ObjectNode basePayload(int pageNum, int pageSize) {
ObjectNode node = objectMapper.createObjectNode();
node.put("pageNum", pageNum);
node.put("pageSize", pageSize);
return node;
}
private ArrayNode range(LocalDateTime start, LocalDateTime end) {
ArrayNode node = objectMapper.createArrayNode();
node.add(DATE_TIME_FORMATTER.format(start));
node.add(DATE_TIME_FORMATTER.format(end));
return node;
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private String ensureCustomerToken() {
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
return customerToken;
}
private void markEvaluated(String orderId) {
PlayOrderEvaluateInfoEntity entity = new PlayOrderEvaluateInfoEntity();
entity.setId("eval-" + IdUtils.getUuid());
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOrderId(orderId);
entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
entity.setAnonymous("0");
entity.setEvaluateType("0");
entity.setEvaluateLevel(5);
entity.setEvaluateCon("Great job");
entity.setEvaluateTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
entity.setHidden("0");
ensureTenantContext();
playOrderEvaluateInfoService.save(entity);
evalIdsToCleanup.add(entity.getId());
}
private void markComplained(String orderId) {
PlayOrderComplaintInfoEntity entity = new PlayOrderComplaintInfoEntity();
entity.setId("complaint-" + IdUtils.getUuid());
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOrderId(orderId);
entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
entity.setComplaintCon("Need assistance");
entity.setComplaintTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
entity.setHidden("0");
ensureTenantContext();
playOrderComplaintInfoService.save(entity);
complaintIdsToCleanup.add(entity.getId());
}
private JsonNode findById(JsonNode records, String id) {
for (JsonNode node : records) {
if (id.equals(node.path("id").asText())) {
return node;
}
}
throw new AssertionError("Record with id " + id + " not found in response");
}
private JsonNode queryOrders(String customerToken, ObjectNode payload) throws Exception {
JsonNode root = executeOrderQuery(customerToken, payload);
JsonNode dataNode = root.path("data");
return dataNode.isArray() ? dataNode : dataNode.path("records");
}
private JsonNode recordsFromRoot(JsonNode root) {
JsonNode dataNode = root.path("data");
return dataNode.isArray() ? dataNode : dataNode.path("records");
}
private JsonNode executeOrderQuery(String customerToken, ObjectNode payload) throws Exception {
MvcResult result = mockMvc.perform(post("/wx/custom/order/queryByPage")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString());
}
}

View File

@@ -562,7 +562,8 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours); LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours);
Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock); Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock);
Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5)); 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); OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd);
Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount) 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> earningsToCleanup = new ArrayList<>();
private final List<String> withdrawalsToCleanup = new ArrayList<>(); private final List<String> withdrawalsToCleanup = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private String clerkToken; private String clerkToken;
private ClerkPayeeProfileEntity payeeProfile; private ClerkPayeeProfileEntity payeeProfile;
@@ -140,7 +141,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200))
.andReturn(); .andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode data = root.get("data"); JsonNode data = root.get("data");
assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50"); assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50");
assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40"); assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40");
@@ -191,7 +192,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data.amount").value(amount.doubleValue())) .andExpect(jsonPath("$.data.amount").value(amount.doubleValue()))
.andReturn(); .andReturn();
JsonNode root = new ObjectMapper().readTree(result.getResponse().getContentAsString()); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String withdrawalId = root.path("data").path("id").asText(); String withdrawalId = root.path("data").path("id").asText();
assertThat(withdrawalId).isNotBlank(); assertThat(withdrawalId).isNotBlank();
@@ -211,6 +212,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId); 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 @Test
void earningsEndpointFiltersByStatus() throws Exception { void earningsEndpointFiltersByStatus() throws Exception {
ensureTenantContext(); ensureTenantContext();
@@ -250,6 +297,15 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
private String insertEarningsLine( private String insertEarningsLine(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) { 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(); EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-" + suffix + "-" + IdUtils.getUuid(); String id = "earn-" + suffix + "-" + IdUtils.getUuid();
entity.setId(id); entity.setId(id);
@@ -260,7 +316,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
entity.setAmount(amount); entity.setAmount(amount);
entity.setStatus(status.getCode()); entity.setStatus(status.getCode());
entity.setUnlockTime(unlockAt); entity.setUnlockTime(unlockAt);
entity.setEarningType(EarningsType.ORDER); entity.setEarningType(earningType);
Date now = toDate(LocalDateTime.now()); Date now = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(now); entity.setCreatedTime(now);
@@ -274,6 +330,39 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); 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) { private Date toDate(LocalDateTime value) {
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
} }

View File

@@ -1,8 +1,11 @@
package com.starry.admin.modules.blindbox.service; package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -20,6 +23,7 @@ import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
@@ -185,6 +189,84 @@ class BlindBoxPoolAdminServiceTest {
assertEquals("超值娃娃", options.get(0).getName()); assertEquals("超值娃娃", options.get(0).getName());
} }
@Test
void shouldOverwriteRemainingStockOnReimport() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
List<BlindBoxPoolEntity> inserted = new ArrayList<>();
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> {
BlindBoxPoolEntity entity = invocation.getArgument(0);
BlindBoxPoolEntity snapshot = new BlindBoxPoolEntity();
snapshot.setBlindBoxId(entity.getBlindBoxId());
snapshot.setRewardGiftId(entity.getRewardGiftId());
snapshot.setRemainingStock(entity.getRemainingStock());
inserted.add(snapshot);
return 1;
});
BlindBoxPoolImportRow first = new BlindBoxPoolImportRow();
first.setRewardGiftName("超值娃娃");
first.setWeight(50);
first.setRemainingStock(5);
first.setStatus(1);
BlindBoxPoolImportRow second = new BlindBoxPoolImportRow();
second.setRewardGiftName("超值娃娃");
second.setWeight(60);
second.setRemainingStock(1);
second.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(first));
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(second));
assertEquals(2, inserted.size());
assertEquals(Integer.valueOf(5), inserted.get(0).getRemainingStock());
assertEquals(Integer.valueOf(1), inserted.get(1).getRemainingStock());
}
@Test
void shouldKeepUnlimitedStockWhenRemainingStockBlank() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
ArgumentCaptor<BlindBoxPoolEntity> captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class);
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenReturn(1);
BlindBoxPoolImportRow importRow = new BlindBoxPoolImportRow();
importRow.setRewardGiftName("超值娃娃");
importRow.setWeight(30);
importRow.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(importRow));
verify(blindBoxPoolMapper).insert(captor.capture());
BlindBoxPoolEntity saved = captor.getValue();
assertNull(saved.getRemainingStock());
}
@Test @Test
void shouldCreatePoolEntry() { void shouldCreatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity(); BlindBoxConfigEntity config = new BlindBoxConfigEntity();
@@ -271,4 +353,68 @@ class BlindBoxPoolAdminServiceTest {
assertEquals("超级公仔", view.getRewardGiftName()); assertEquals("超级公仔", view.getRewardGiftName());
verify(blindBoxPoolMapper).updateById(existing); verify(blindBoxPoolMapper).updateById(existing);
} }
@Test
void shouldAllowUpdateWhenGiftInactiveOrHistorical() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity();
inactiveGift.setId("gift-offline");
inactiveGift.setTenantId("tenant-1");
inactiveGift.setHistory("1");
inactiveGift.setState("1");
inactiveGift.setType("1");
inactiveGift.setPrice(BigDecimal.valueOf(66.6));
inactiveGift.setName("下架礼物");
when(playGiftInfoMapper.selectById("gift-offline")).thenReturn(inactiveGift);
BlindBoxPoolEntity existing = new BlindBoxPoolEntity();
existing.setId(500L);
existing.setTenantId("tenant-1");
existing.setBlindBoxId("blind-1");
existing.setRewardGiftId("gift-on");
existing.setStatus(1);
when(blindBoxPoolMapper.selectById(500L)).thenReturn(existing);
when(blindBoxPoolMapper.updateById(existing)).thenReturn(1);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-offline");
request.setWeight(10);
request.setStatus(1);
blindBoxPoolAdminService.update(500L, request);
verify(blindBoxPoolMapper).updateById(existing);
assertEquals("gift-offline", existing.getRewardGiftId());
}
@Test
void shouldRejectCreateWhenGiftInactive() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity();
inactiveGift.setId("gift-off");
inactiveGift.setTenantId("tenant-1");
inactiveGift.setHistory("1");
inactiveGift.setState("1");
inactiveGift.setType("1");
when(playGiftInfoMapper.selectById("gift-off")).thenReturn(inactiveGift);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-off");
request.setWeight(10);
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.create("blind-1", request));
assertTrue(ex.getMessage().contains("中奖礼物不存在或已下架"));
verify(blindBoxPoolMapper, times(0)).insert(any());
}
} }

View File

@@ -171,6 +171,21 @@ class BlindBoxServiceTest {
verify(rewardMapper, times(0)).markUsed(any(), any(), any(), any()); verify(rewardMapper, times(0)).markUsed(any(), any(), any(), any());
} }
@Test
void shouldRejectWhenNoEligibleRewardExists() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(configService.requireById("blind-1")).thenReturn(config);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxService.drawReward("tenant-1", "order-404", "customer-9", "blind-1", "seed-out"));
assertTrue(ex.getMessage().contains("奖池暂无可用奖励"));
verify(inventoryService, times(0)).reserveRewardStock(any(), any(), any());
}
private BlindBoxRewardEntity buildRewardEntity() { private BlindBoxRewardEntity buildRewardEntity() {
BlindBoxRewardEntity reward = new BlindBoxRewardEntity(); BlindBoxRewardEntity reward = new BlindBoxRewardEntity();
reward.setId("reward-1"); reward.setId("reward-1");

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

@@ -0,0 +1,82 @@
package com.starry.admin.modules.order.listener;
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.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderRevocationEarningsListenerTest {
@Mock
private IEarningsService earningsService;
private OrderRevocationEarningsListener listener;
@BeforeEach
void setUp() {
listener = new OrderRevocationEarningsListener(earningsService);
}
@Test
void handle_deductCreatesCounterLineUsingOrderClerk() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-2");
context.setOperatorId("admin-reviewer");
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(25));
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-reverse-2");
order.setTenantId("tenant-x");
order.setAcceptBy("clerk-special");
listener.handle(new OrderRevocationEvent(context, order));
verify(earningsService)
.createCounterLine("order-reverse-2", "tenant-x", "clerk-special", BigDecimal.valueOf(25), "admin-reviewer");
}
@Test
void handle_deductFallsBackToEstimatedWhenAmountMissing() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-reverse-3");
context.setOperatorId("admin-reviewer");
context.setDeductClerkEarnings(true);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
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)
.createCounterLine("order-reverse-3", "tenant-y", "clerk-owner", BigDecimal.valueOf(52), "admin-reviewer");
}
@Test
void handle_disabledDeductIsNoOp() {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId("order-none-3");
context.setOperatorId("admin-noop");
context.setDeductClerkEarnings(false);
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-none-3");
listener.handle(new OrderRevocationEvent(context, order));
verifyNoInteractions(earningsService);
}
}

View File

@@ -1,7 +1,9 @@
package com.starry.admin.modules.order.service.impl; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
@@ -17,6 +19,7 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.constant.OrderConstant.CommodityType; import com.starry.admin.modules.order.module.constant.OrderConstant.CommodityType;
import com.starry.admin.modules.order.module.constant.OrderConstant.Gender; import com.starry.admin.modules.order.module.constant.OrderConstant.Gender;
@@ -40,9 +43,11 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; 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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; 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.PaymentInfo; 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.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo; import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
@@ -65,10 +70,12 @@ import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class OrderLifecycleServiceImplTest { class OrderLifecycleServiceImplTest {
@@ -106,11 +113,173 @@ class OrderLifecycleServiceImplTest {
@Mock @Mock
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Mock
private ApplicationEventPublisher applicationEventPublisher;
@BeforeEach @BeforeEach
void initStrategies() { void initStrategies() {
lifecycleService.initPlacementStrategies(); lifecycleService.initPlacementStrategies();
} }
@Test
void revokeCompletedOrder_updatesStatusAndPublishesEvent() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setFinalAmount(BigDecimal.valueOf(188));
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
revoked.setFinalAmount(BigDecimal.valueOf(188));
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);
context.setOperatorId("admin-8");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundAmount(BigDecimal.valueOf(88));
context.setRefundReason("客户投诉");
context.setRefundToCustomer(true);
context.setDeductClerkEarnings(true);
lifecycleService.revokeCompletedOrder(context);
verify(orderInfoMapper).update(isNull(), any());
verify(orderLogInfoMapper).insert(any());
ArgumentCaptor<OrderRevocationEvent> captor = ArgumentCaptor.forClass(OrderRevocationEvent.class);
verify(applicationEventPublisher).publishEvent(captor.capture());
OrderRevocationEvent event = captor.getValue();
assertEquals(orderId, event.getContext().getOrderId());
assertTrue(event.getContext().isDeductClerkEarnings());
assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount());
assertEquals(BigDecimal.valueOf(60), event.getContext().getEarningsAdjustAmount());
}
@Test
void revokeCompletedOrder_defersBalanceCreditToListener() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
completed.setFinalAmount(BigDecimal.valueOf(208));
completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode());
revoked.setFinalAmount(BigDecimal.valueOf(208));
when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked);
lenient().when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(completed.getPurchaserBy(), orderId))
.thenReturn(true);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-9");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundAmount(BigDecimal.valueOf(108));
context.setRefundReason("质量问题");
context.setRefundToCustomer(true);
context.setDeductClerkEarnings(false);
lifecycleService.revokeCompletedOrder(context);
verify(customUserInfoService, never()).getById(anyString());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
}
@Test
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, 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.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(10));
assertDoesNotThrow(() -> lifecycleService.revokeCompletedOrder(context));
verify(orderInfoMapper).update(isNull(), any());
verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class));
}
@Test
void revokeCompletedOrder_throwsWhenDeductExceedsAvailable() {
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.valueOf(40));
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-counter");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setRefundToCustomer(false);
context.setRefundAmount(BigDecimal.ZERO);
context.setDeductClerkEarnings(true);
context.setEarningsAdjustAmount(BigDecimal.valueOf(50));
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("扣回金额不能超过本单收益40", ex.getMessage());
}
@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
void revokeCompletedOrder_rejectsNonNormalOrders() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity giftOrder = buildOrder(orderId, OrderStatus.COMPLETED.getCode());
giftOrder.setOrderType(OrderConstant.OrderType.GIFT.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(giftOrder);
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(orderId);
context.setOperatorId("admin-block");
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setDeductClerkEarnings(false);
CustomException ex = assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context));
assertEquals("仅支持撤销普通服务订单", ex.getMessage());
verify(orderInfoMapper, never()).update(isNull(), any());
}
@Test @Test
void placeOrder_throwsWhenCommandNull() { void placeOrder_throwsWhenCommandNull() {
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null)); assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null));
@@ -1217,12 +1386,20 @@ class OrderLifecycleServiceImplTest {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
entity.setOrderStatus(status); entity.setOrderStatus(status);
entity.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
entity.setAcceptBy("clerk-1"); entity.setAcceptBy("clerk-1");
entity.setPurchaserBy("customer-1"); entity.setPurchaserBy("customer-1");
entity.setTenantId("tenant-1"); entity.setTenantId("tenant-1");
return entity; return entity;
} }
private PlayCustomUserInfoEntity buildCustomer(String id, BigDecimal balance) {
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(id);
entity.setAccountBalance(balance);
return entity;
}
private void stubDefaultPersistence() { private void stubDefaultPersistence() {
lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L); lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L);
lenient().when(orderInfoMapper.insert(any())).thenReturn(1); lenient().when(orderInfoMapper.insert(any())).thenReturn(1);

View File

@@ -25,13 +25,17 @@ import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl; import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.utils.DateRangeUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Month; import java.time.Month;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -318,6 +322,106 @@ class PlayClerkPerformanceServiceImplTest {
Arrays.asList(o1.getId(), o2.getId(), o3.getId(), o4.getId(), o5.getId()))); Arrays.asList(o1.getId(), o2.getId(), o3.getId(), o4.getId(), o5.getId())));
} }
@Test @Test
@DisplayName("queryOverview filters completed orders according to complex date/time ranges")
void queryOverviewHonorsDateRangesForMultipleClerks() {
PlayClerkUserInfoEntity alpha = buildClerk("c-alpha", "Alpha", "g-alpha", "l-alpha");
PlayClerkUserInfoEntity beta = buildClerk("c-beta", "Beta", "g-beta", "l-beta");
PlayClerkUserInfoEntity gamma = buildClerk("c-gamma", "Gamma", "g-gamma", "l-gamma");
List<PlayClerkUserInfoEntity> clerks = Arrays.asList(alpha, beta, gamma);
when(playPersonnelGroupInfoService.getValidClerkIdList(any(), any()))
.thenReturn(Arrays.asList(alpha.getId(), beta.getId(), gamma.getId()));
when(clerkUserInfoService.list((Wrapper<PlayClerkUserInfoEntity>) any())).thenReturn(clerks);
when(playClerkLevelInfoService.selectAll()).thenReturn(Arrays.asList(
level(alpha.getLevelId(), "铂金"),
level(beta.getLevelId(), "黄金"),
level(gamma.getLevelId(), "白银")));
when(playPersonnelGroupInfoService.selectAll()).thenReturn(Arrays.asList(
group(alpha.getGroupId(), "一组"),
group(beta.getGroupId(), "二组"),
group(gamma.getGroupId(), "三组")));
Map<String, List<PlayOrderInfoEntity>> ordersByClerk = new HashMap<>();
ordersByClerk.put(alpha.getId(), Arrays.asList(
order(alpha.getId(), "userA1", "1", "0", "0", new BigDecimal("100.00"), new BigDecimal("70.00"),
LocalDateTime.of(2024, Month.JULY, 1, 10, 0)),
order(alpha.getId(), "userA2", "0", "0", "0", new BigDecimal("210.00"), new BigDecimal("120.00"),
LocalDateTime.of(2024, Month.JULY, 3, 15, 30)),
order(alpha.getId(), "userA3", "0", "0", "0", new BigDecimal("150.00"), new BigDecimal("80.00"),
LocalDateTime.of(2024, Month.JULY, 7, 9, 15)),
order(alpha.getId(), "userA4", "0", "0", "0", new BigDecimal("130.00"), new BigDecimal("75.00"),
LocalDateTime.of(2024, Month.JULY, 10, 18, 45))));
ordersByClerk.put(beta.getId(), Arrays.asList(
order(beta.getId(), "userB1", "1", "0", "0", new BigDecimal("95.00"), new BigDecimal("50.00"),
LocalDateTime.of(2024, Month.JULY, 2, 11, 30)),
order(beta.getId(), "userB2", "0", "0", "0", new BigDecimal("120.00"), new BigDecimal("65.00"),
LocalDateTime.of(2024, Month.JULY, 4, 13, 0)),
order(beta.getId(), "userB3", "0", "0", "0", new BigDecimal("85.00"), new BigDecimal("40.00"),
LocalDateTime.of(2024, Month.JULY, 8, 16, 0)),
order(beta.getId(), "userB4", "0", "0", "0", new BigDecimal("150.00"), new BigDecimal("85.00"),
LocalDateTime.of(2024, Month.JULY, 10, 19, 0))));
ordersByClerk.put(gamma.getId(), Arrays.asList(
order(gamma.getId(), "userC1", "1", "0", "0", new BigDecimal("70.00"), new BigDecimal("35.00"),
LocalDateTime.of(2024, Month.JULY, 1, 8, 0)),
order(gamma.getId(), "userC2", "0", "0", "0", new BigDecimal("135.00"), new BigDecimal("70.00"),
LocalDateTime.of(2024, Month.JULY, 5, 17, 20)),
order(gamma.getId(), "userC3", "0", "0", "0", new BigDecimal("75.00"), new BigDecimal("45.00"),
LocalDateTime.of(2024, Month.JULY, 6, 14, 50)),
order(gamma.getId(), "userC4", "0", "0", "0", new BigDecimal("160.00"), new BigDecimal("90.00"),
LocalDateTime.of(2024, Month.JULY, 9, 21, 10))));
when(playOrderInfoService.clerkSelectOrderInfoList(anyString(), anyString(), anyString()))
.thenAnswer(invocation -> {
String clerkId = invocation.getArgument(0);
String startStr = invocation.getArgument(1);
String endStr = invocation.getArgument(2);
LocalDateTime start = LocalDateTime.parse(startStr, DateRangeUtils.DATE_TIME_FORMATTER);
LocalDateTime end = LocalDateTime.parse(endStr, DateRangeUtils.DATE_TIME_FORMATTER);
return ordersByClerk.getOrDefault(clerkId, Collections.emptyList()).stream()
.filter(order -> !order.getPurchaserTime().isBefore(start)
&& !order.getPurchaserTime().isAfter(end))
.collect(Collectors.toList());
});
setAuthentication();
try {
ClerkPerformanceOverviewResponseVo fullRange =
service.queryOverview(buildOverviewVo("2024-07-01", "2024-07-10"));
ClerkPerformanceOverviewSummaryVo fullSummary = fullRange.getSummary();
assertEquals(new BigDecimal("1480.00"), fullSummary.getTotalGmv());
assertEquals(12, fullSummary.getTotalOrderCount());
assertEquals(new BigDecimal("590.00"), snapshotFor(fullRange.getRankings(), alpha.getId()).getGmv());
assertEquals(new BigDecimal("450.00"), snapshotFor(fullRange.getRankings(), beta.getId()).getGmv());
assertEquals(new BigDecimal("440.00"), snapshotFor(fullRange.getRankings(), gamma.getId()).getGmv());
ClerkPerformanceOverviewResponseVo midRange =
service.queryOverview(buildOverviewVo("2024-07-04", "2024-07-08"));
ClerkPerformanceOverviewSummaryVo midSummary = midRange.getSummary();
assertEquals(5, midSummary.getTotalOrderCount());
assertEquals(new BigDecimal("565.00"), midSummary.getTotalGmv());
List<ClerkPerformanceSnapshotVo> midRankings = midRange.getRankings();
assertEquals("c-gamma", midRankings.get(0).getClerkId());
assertEquals(new BigDecimal("210.00"), midRankings.get(0).getGmv());
assertEquals("c-beta", midRankings.get(1).getClerkId());
assertEquals(new BigDecimal("205.00"), midRankings.get(1).getGmv());
assertEquals("c-alpha", midRankings.get(2).getClerkId());
assertEquals(new BigDecimal("150.00"), midRankings.get(2).getGmv());
ClerkPerformanceOverviewResponseVo shortRange = service.queryOverview(
buildOverviewVo("2024-07-10 18:30:00", "2024-07-10 18:59:59"));
ClerkPerformanceOverviewSummaryVo shortSummary = shortRange.getSummary();
assertEquals(1, shortSummary.getTotalOrderCount());
assertEquals(new BigDecimal("130.00"), shortSummary.getTotalGmv());
ClerkPerformanceSnapshotVo alphaSnapshot = snapshotFor(shortRange.getRankings(), alpha.getId());
assertEquals(new BigDecimal("130.00"), alphaSnapshot.getGmv());
assertEquals(1, alphaSnapshot.getOrderCount());
assertEquals(BigDecimal.ZERO, snapshotFor(shortRange.getRankings(), beta.getId()).getGmv());
assertEquals(BigDecimal.ZERO, snapshotFor(shortRange.getRankings(), gamma.getId()).getGmv());
} finally {
clearAuthentication();
}
}
@Test
@DisplayName("getClerkPerformanceInfo should ignore non-completed orders when aggregating GMV") @DisplayName("getClerkPerformanceInfo should ignore non-completed orders when aggregating GMV")
void getClerkPerformanceInfoSkipsNonCompletedOrders() { void getClerkPerformanceInfoSkipsNonCompletedOrders() {
PlayClerkUserInfoEntity clerk = buildClerk("c9", "Nine", "gX", "lX"); PlayClerkUserInfoEntity clerk = buildClerk("c9", "Nine", "gX", "lX");
@@ -348,6 +452,22 @@ class PlayClerkPerformanceServiceImplTest {
assertTrue(idCaptor.getValue().contains(completed.getId())); assertTrue(idCaptor.getValue().contains(completed.getId()));
} }
private ClerkPerformanceOverviewQueryVo buildOverviewVo(String start, String end) {
ClerkPerformanceOverviewQueryVo vo = new ClerkPerformanceOverviewQueryVo();
vo.setEndOrderTime(Arrays.asList(start, end));
vo.setIncludeSummary(true);
vo.setIncludeRankings(true);
vo.setLimit(10);
return vo;
}
private ClerkPerformanceSnapshotVo snapshotFor(List<ClerkPerformanceSnapshotVo> snapshots, String clerkId) {
return snapshots.stream()
.filter(snapshot -> clerkId.equals(snapshot.getClerkId()))
.findFirst()
.orElseThrow(() -> new AssertionError("Snapshot not found for clerk " + clerkId));
}
private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) { private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) {
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity(); PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(id); entity.setId(id);

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,17 +1,23 @@
package com.starry.admin.modules.withdraw.service.impl; package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.any;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService; import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -68,4 +74,150 @@ class EarningsServiceImplTest {
verify(baseMapper, never()).insert(any()); verify(baseMapper, never()).insert(any());
} }
@Test
void createFromOrder_skipsBlindBoxPurchaseOrders() {
PlayOrderInfoEntity order = baselineOrder();
order.setEstimatedRevenue(BigDecimal.valueOf(66));
order.setOrderType(OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode());
earningsService.createFromOrder(order);
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");
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
EarningsLineEntity saved = captor.getValue();
assertEquals(new BigDecimal("-88"), saved.getAmount());
assertEquals("clerk-c", saved.getClerkId());
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();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg", new BigDecimal("-60")),
line("pos", new BigDecimal("40")));
when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines);
List<EarningsLineEntity> picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(30), now);
assertEquals(0, picked.size(), "净额不足时不应允许提现");
}
@Test
void findWithdrawable_allowsWithdrawalAfterPositiveLinesCoverDebt() {
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = Arrays.asList(
line("neg", new BigDecimal("-60")),
line("first", new BigDecimal("40")),
line("second", new BigDecimal("150")));
when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines);
List<EarningsLineEntity> picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(70), now);
assertEquals(3, picked.size());
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);
entity.setAmount(amount);
entity.setStatus("available");
return entity;
}
private List<String> ids(List<EarningsLineEntity> entities) {
return entities.stream().map(EarningsLineEntity::getId).collect(java.util.stream.Collectors.toList());
}
} }