This commit is contained in:
dzc committed 2026-11-11 11:12:07 +08:00
1 parent 074307b07b
commit a099e72517
47 files changed
+1901 -65

No files matched your search

@@ -0,0 +1,20 @@
package cn.iocoder.yudao.framework.excel.core.style;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.VerticalAlignment;
/**
* Excel 导出的全局样式配置。
*/
public final class ExcelExportStyleTokens {
/** 表头与数据行的统一高度,单位为磅。 */
public static final short ROW_HEIGHT = 32;
public static final HorizontalAlignment HEAD_HORIZONTAL_ALIGNMENT = HorizontalAlignment.CENTER;
public static final VerticalAlignment VERTICAL_ALIGNMENT = VerticalAlignment.CENTER;
private ExcelExportStyleTokens() {
}
}
@@ -2,9 +2,14 @@ package cn.iocoder.yudao.framework.excel.core.util;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.excel.core.handler.SelectSheetWriteHandler;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.converters.longconverter.LongStringConverter;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import com.alibaba.excel.write.style.row.SimpleRowHeightStyleStrategy;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
@@ -18,6 +23,19 @@ import java.util.List;
*/
public class ExcelUtils {
/** 创建使用全局行高及表头样式的导出构建器,支持响应流和分批文件导出。 */
public static ExcelWriterBuilder writeBuilder(Class<?> head) {
WriteCellStyle headStyle = new WriteCellStyle();
headStyle.setHorizontalAlignment(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
headStyle.setVerticalAlignment(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
WriteCellStyle contentStyle = new WriteCellStyle();
contentStyle.setVerticalAlignment(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
return EasyExcel.write().head(head)
.registerWriteHandler(new SimpleRowHeightStyleStrategy(
ExcelExportStyleTokens.ROW_HEIGHT, ExcelExportStyleTokens.ROW_HEIGHT))
.registerWriteHandler(new HorizontalCellStyleStrategy(headStyle, contentStyle));
}
/**
* 将列表以 Excel 响应给前端
*
@@ -32,7 +50,7 @@ public class ExcelUtils {
public static <T> void write(HttpServletResponse response, String filename, String sheetName,
Class<T> head, List<T> data) throws IOException {
// 输出 Excel
EasyExcel.write(response.getOutputStream(), head)
writeBuilder(head).file(response.getOutputStream())
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度
.registerWriteHandler(new SelectSheetWriteHandler(head)) // 基于固定 sheet 实现下拉框
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.framework.excel.core.util;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.ContentStyle;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import com.alibaba.excel.annotation.write.style.HeadStyle;
import com.alibaba.excel.enums.poi.HorizontalAlignmentEnum;
import com.alibaba.excel.enums.poi.VerticalAlignmentEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.poi.ss.usermodel.*;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
class ExcelUtilsTest {
@Test
void sharedExportUsesGlobalHeightAndCenteredHeadersAndPreservesContentFormatting() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
ExcelUtils.write(response, "会员.xlsx", "会员", ExportRow.class,
Collections.singletonList(new ExportRow(9007199254740993L, "测试会员")));
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet sheet = workbook.getSheetAt(0);
assertHeaderStyle(sheet);
Row row = sheet.getRow(2);
assertThat(row.getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
assertThat(row.getCell(0).getStringCellValue()).isEqualTo("9007199254740993");
assertThat(row.getCell(1).getStringCellValue()).isEqualTo("测试会员");
assertThat(row.getCell(1).getCellStyle().getAlignment()).isEqualTo(HorizontalAlignment.RIGHT);
assertThat(row.getCell(1).getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
assertThat(workbook.getFontAt(sheet.getRow(0).getCell(0).getCellStyle().getFontIndex()).getBold()).isTrue();
}
assertThat(response.getHeader("Content-Disposition")).startsWith("attachment;filename=");
}
@Test
void emptyExportsAlsoUseGlobalHeightAndCenteredMultilevelHeaders() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
ExcelUtils.write(response, "会员.xlsx", "会员", ExportRow.class, Collections.emptyList());
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getLastRowNum()).isEqualTo(1);
assertHeaderStyle(sheet);
}
}
private void assertHeaderStyle(Sheet sheet) {
for (int rowIndex = 0; rowIndex < 2; rowIndex++) {
Row header = sheet.getRow(rowIndex);
assertThat(header.getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
for (Cell cell : header) {
assertThat(cell.getCellStyle().getAlignment()).isEqualTo(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
assertThat(cell.getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
}
}
}
@Data
@AllArgsConstructor
@HeadRowHeight(12)
@ContentRowHeight(13)
@HeadStyle(horizontalAlignment = HorizontalAlignmentEnum.LEFT, verticalAlignment = VerticalAlignmentEnum.TOP)
@ContentStyle(horizontalAlignment = HorizontalAlignmentEnum.RIGHT, verticalAlignment = VerticalAlignmentEnum.TOP)
public static class ExportRow {
@ExcelProperty({"会员信息", "用户编号"})
private Long id;
@ExcelProperty({"会员信息", "昵称"})
private String nickname;
}
}
@@ -1,17 +1,32 @@
package cn.iocoder.yudao.module.trade.api.brokerage;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageWechatTransferDTO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import javax.validation.Valid;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 分销记录 API 接口
*/
public interface TradeBrokerageRecordApi {
/** 批量查询会员自身收益,包含零收益会员,金额单位为分。 */
Map<Long, BrokerageIncomeSummaryRespDTO> getIncomeSummaryMap(Collection<Long> userIds);
/** 按来源会员查询其为指定收款会员产生的收益,金额单位为分。 */
Map<Long, BrokerageIncomeSummaryRespDTO> getContributionSummaryMap(Long recipientUserId,
Collection<Long> sourceUserIds);
/** 按业务类型和业务编号批量查询每单实际收益人及各状态佣金。 */
Map<String, BrokerageOrderSummaryRespDTO> getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType,
Collection<String> bizIds);
/**
* 添加朋友圈分销【多级分佣】
* 首次分销才有用
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.trade.api.brokerage.dto;
import lombok.Data;
/**
* 分销收益汇总,金额单位为分。
*/
@Data
public class BrokerageIncomeSummaryRespDTO {
/** 已结算的累计收益,提现不扣减。 */
private Long settledPrice = 0L;
/** 待结算收益。 */
private Long pendingPrice = 0L;
}
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.trade.api.brokerage.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.ArrayList;
import java.util.List;
/** 单笔业务的分销佣金,按实际收益人和分销层级汇总,金额单位为分。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class BrokerageOrderSummaryRespDTO extends BrokerageIncomeSummaryRespDTO {
private Long cancelledPrice = 0L;
private List<Recipient> recipients = new ArrayList<>();
@Data
@EqualsAndHashCode(callSuper = true)
public static class Recipient extends BrokerageIncomeSummaryRespDTO {
private Long userId;
private Integer sourceUserLevel;
private Long cancelledPrice = 0L;
}
}
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.trade.enums.brokerage;
/**
* 分销收益查询共用的条件。状态、逻辑删除及租户由查询方处理。
*/
public final class BrokerageIncomeSql {
public static final String CONDITION = condition("");
private BrokerageIncomeSql() {
}
/**
* @param alias 代码中固定的表别名,空字符串表示无别名
*/
public static String condition(String alias) {
String prefix = alias.isEmpty() ? "" : alias + ".";
// 历史数据的业务类型存在错位,沿用佣金列表对提现文本的排除规则。
return prefix + "biz_type IN (1, 2) AND " + prefix + "price > 0"
+ " AND COALESCE(" + prefix + "title, '') NOT LIKE '%提现%'"
+ " AND COALESCE(" + prefix + "description, '') NOT LIKE '%提现%'";
}
}
@@ -13,6 +13,8 @@ import cn.iocoder.yudao.module.system.api.social.SocialUserApi;
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageWechatTransferDTO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO;
@@ -49,6 +51,23 @@ public class TradeBrokerageRecordApiImpl implements TradeBrokerageRecordApi {
@Resource
private PayTransferApi payTransferApi;
@Override
public Map<Long, BrokerageIncomeSummaryRespDTO> getIncomeSummaryMap(Collection<Long> userIds) {
return brokerageRecordService.getIncomeSummaryMap(userIds);
}
@Override
public Map<Long, BrokerageIncomeSummaryRespDTO> getContributionSummaryMap(Long recipientUserId,
Collection<Long> sourceUserIds) {
return brokerageRecordService.getContributionSummaryMap(recipientUserId, sourceUserIds);
}
@Override
public Map<String, BrokerageOrderSummaryRespDTO> getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType,
Collection<String> bizIds) {
return brokerageRecordService.getOrderSummaryMap(bizType, bizIds);
}
/**
* 添加朋友圈分销【多级分佣】
* 首次分销才有用
@@ -20,9 +20,18 @@ public class BrokerageRecordPageReqVO extends PageParam {
@Schema(description = "用户编号", example = "25973")
private Long userId;
@Schema(description = "来源会员编号", example = "1125")
private Long sourceUserId;
@Schema(description = "仅查询分销收益记录")
private Boolean incomeOnly;
@Schema(description = "业务类型", example = "1")
private Integer bizType;
@Schema(description = "业务编号,会员佣金对应会员开通记录编号")
private String bizId;
@Schema(description = "状态", example = "1")
private Integer status;
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.trade.controller.admin.order.vo;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
@@ -14,8 +15,8 @@ import lombok.experimental.Accessors;
@Schema(description = "管理后台 - 商家发货模版 Response VO")
@Data
@ExcelIgnoreUnannotated
@HeadRowHeight(25)
@ContentRowHeight(25)
@HeadRowHeight(ExcelExportStyleTokens.ROW_HEIGHT)
@ContentRowHeight(ExcelExportStyleTokens.ROW_HEIGHT)
@Accessors(chain = false)
public class TradeMchDeliveryImportVo {
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.trade.controller.admin.order.vo;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
@@ -16,8 +17,8 @@ import java.time.LocalDateTime;
@Schema(description = "管理后台 - 订单导出 Response VO")
@Data
@ExcelIgnoreUnannotated
@HeadRowHeight(25)
@ContentRowHeight(25)
@HeadRowHeight(ExcelExportStyleTokens.ROW_HEIGHT)
@ContentRowHeight(ExcelExportStyleTokens.ROW_HEIGHT)
public class TradeOrderListExportVo {
@ExcelProperty("订单编号")
@@ -1,14 +1,20 @@
package cn.iocoder.yudao.module.trade.dal.mysql.brokerage;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByPriceRespVO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageIncomeSql;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum;
import cn.iocoder.yudao.module.trade.service.brokerage.bo.UserBrokerageSummaryRespBO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.yulichang.toolkit.MPJWrappers;
import org.apache.ibatis.annotations.Mapper;
@@ -17,6 +23,7 @@ import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -31,11 +38,42 @@ public interface BrokerageRecordMapper extends BaseMapperX<BrokerageRecordDO> {
default PageResult<BrokerageRecordDO> selectPage(BrokerageRecordPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<BrokerageRecordDO>()
.eqIfPresent(BrokerageRecordDO::getUserId, reqVO.getUserId())
.eqIfPresent(BrokerageRecordDO::getSourceUserId, reqVO.getSourceUserId())
.eqIfPresent(BrokerageRecordDO::getBizType, reqVO.getBizType())
.eqIfPresent(BrokerageRecordDO::getBizId, reqVO.getBizId())
.eqIfPresent(BrokerageRecordDO::getStatus, reqVO.getStatus())
.eqIfPresent(BrokerageRecordDO::getSourceUserLevel, reqVO.getSourceUserLevel())
.betweenIfPresent(BrokerageRecordDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(BrokerageRecordDO::getId));
.orderByDesc(BrokerageRecordDO::getId)
.apply(Boolean.TRUE.equals(reqVO.getIncomeOnly()), BrokerageIncomeSql.CONDITION));
}
/** recipientUserId 为空时按收款人汇总,否则按指定收款人的来源会员汇总。 */
default Map<Long, BrokerageIncomeSummaryRespDTO> selectIncomeSummaryMap(Collection<Long> userIds,
Long recipientUserId) {
Map<Long, BrokerageIncomeSummaryRespDTO> result = new LinkedHashMap<>();
userIds.forEach(userId -> result.put(userId, new BrokerageIncomeSummaryRespDTO()));
if (userIds.isEmpty()) {
return result;
}
String groupColumn = recipientUserId == null ? "user_id" : "source_user_id";
QueryWrapper<BrokerageRecordDO> query = new QueryWrapper<BrokerageRecordDO>()
.select(groupColumn + " AS summaryUserId",
"SUM(CASE WHEN status = 1 THEN price ELSE 0 END) AS settledPrice",
"SUM(CASE WHEN status = 0 THEN price ELSE 0 END) AS pendingPrice")
.in(groupColumn, userIds)
.eq(recipientUserId != null, "user_id", recipientUserId)
.in("status", BrokerageRecordStatusEnum.WAIT_SETTLEMENT.getStatus(),
BrokerageRecordStatusEnum.SETTLEMENT.getStatus())
.apply(BrokerageIncomeSql.CONDITION)
.groupBy(groupColumn);
for (Map<String, Object> row : selectMaps(query)) {
BrokerageIncomeSummaryRespDTO summary = new BrokerageIncomeSummaryRespDTO();
summary.setSettledPrice(MapUtil.getLong(row, "settledPrice", 0L));
summary.setPendingPrice(MapUtil.getLong(row, "pendingPrice", 0L));
result.put(MapUtil.getLong(row, "summaryUserId"), summary);
}
return result;
}
default List<BrokerageRecordDO> selectListByStatusAndUnfreezeTimeLt(Integer status, LocalDateTime unfreezeTime) {
@@ -44,6 +82,41 @@ public interface BrokerageRecordMapper extends BaseMapperX<BrokerageRecordDO> {
.lt(BrokerageRecordDO::getUnfreezeTime, unfreezeTime));
}
default Map<String, BrokerageOrderSummaryRespDTO> selectOrderSummaryMap(Integer bizType,
Collection<String> bizIds) {
Map<String, BrokerageOrderSummaryRespDTO> result = new LinkedHashMap<>();
bizIds.forEach(id -> result.put(id, new BrokerageOrderSummaryRespDTO()));
if (bizIds.isEmpty()) {
return result;
}
QueryWrapper<BrokerageRecordDO> query = new QueryWrapper<BrokerageRecordDO>()
.select("biz_id AS bizId", "user_id AS userId", "source_user_level AS sourceUserLevel",
"SUM(CASE WHEN status = 1 THEN price ELSE 0 END) AS settledPrice",
"SUM(CASE WHEN status = 0 THEN price ELSE 0 END) AS pendingPrice",
"SUM(CASE WHEN status = 2 THEN price ELSE 0 END) AS cancelledPrice")
.eq("biz_type", bizType)
.in("biz_id", bizIds)
.in("status", BrokerageRecordStatusEnum.WAIT_SETTLEMENT.getStatus(),
BrokerageRecordStatusEnum.SETTLEMENT.getStatus(), BrokerageRecordStatusEnum.CANCEL.getStatus())
.apply(BrokerageIncomeSql.CONDITION)
.groupBy("biz_id", "user_id", "source_user_level")
.orderByAsc("biz_id", "source_user_level", "user_id");
for (Map<String, Object> row : selectMaps(query)) {
BrokerageOrderSummaryRespDTO.Recipient recipient = new BrokerageOrderSummaryRespDTO.Recipient();
recipient.setUserId(MapUtil.getLong(row, "userId"));
recipient.setSourceUserLevel(MapUtil.getInt(row, "sourceUserLevel"));
recipient.setSettledPrice(MapUtil.getLong(row, "settledPrice", 0L));
recipient.setPendingPrice(MapUtil.getLong(row, "pendingPrice", 0L));
recipient.setCancelledPrice(MapUtil.getLong(row, "cancelledPrice", 0L));
BrokerageOrderSummaryRespDTO summary = result.get(MapUtil.getStr(row, "bizId"));
summary.setSettledPrice(summary.getSettledPrice() + recipient.getSettledPrice());
summary.setPendingPrice(summary.getPendingPrice() + recipient.getPendingPrice());
summary.setCancelledPrice(summary.getCancelledPrice() + recipient.getCancelledPrice());
summary.getRecipients().add(recipient);
}
return result;
}
default int updateByIdAndStatus(Integer id, Integer status, BrokerageRecordDO updateObj) {
return update(updateObj, new LambdaQueryWrapper<BrokerageRecordDO>()
.eq(BrokerageRecordDO::getId, id)
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.trade.service.brokerage;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageProductPriceRespVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByPriceRespVO;
@@ -26,6 +28,14 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
*/
public interface BrokerageRecordService {
Map<Long, BrokerageIncomeSummaryRespDTO> getIncomeSummaryMap(Collection<Long> userIds);
Map<Long, BrokerageIncomeSummaryRespDTO> getContributionSummaryMap(Long recipientUserId,
Collection<Long> sourceUserIds);
Map<String, BrokerageOrderSummaryRespDTO> getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType,
Collection<String> bizIds);
/**
* 获得佣金记录
*
@@ -12,6 +12,8 @@ import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageProductPriceRespVO;
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByPriceRespVO;
@@ -64,6 +66,34 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService {
@Resource
private ProductSkuApi productSkuApi;
@Override
public Map<Long, BrokerageIncomeSummaryRespDTO> getIncomeSummaryMap(Collection<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return Collections.emptyMap();
}
return brokerageRecordMapper.selectIncomeSummaryMap(userIds, null);
}
@Override
public Map<Long, BrokerageIncomeSummaryRespDTO> getContributionSummaryMap(Long recipientUserId,
Collection<Long> sourceUserIds) {
if (CollUtil.isEmpty(sourceUserIds)) {
return Collections.emptyMap();
}
Objects.requireNonNull(recipientUserId, "贡献收益的收款会员不能为空");
return brokerageRecordMapper.selectIncomeSummaryMap(sourceUserIds, recipientUserId);
}
@Override
public Map<String, BrokerageOrderSummaryRespDTO> getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType,
Collection<String> bizIds) {
if (CollUtil.isEmpty(bizIds)) {
return Collections.emptyMap();
}
Objects.requireNonNull(bizType, "佣金业务类型不能为空");
return brokerageRecordMapper.selectOrderSummaryMap(bizType.getType(), bizIds);
}
@Override
public BrokerageRecordDO getBrokerageRecord(Long id) {
return brokerageRecordMapper.selectById(id);
@@ -0,0 +1,275 @@
package cn.iocoder.yudao.module.trade.service.brokerage;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO;
import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRecordMapper;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageIncomeSql;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import cn.iocoder.yudao.module.trade.service.config.TradeConfigService;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.jdbc.Sql;
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
@Import(BrokerageRecordServiceImpl.class)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:brokerage_income;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
"spring.sql.init.schema-locations=classpath:/sql/brokerage_income_schema.sql"
})
@Sql(statements = "DELETE FROM trade_brokerage_record", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class BrokerageIncomeQueryTest extends BaseDbUnitTest {
@Resource private BrokerageRecordServiceImpl service;
@Resource private BrokerageRecordMapper mapper;
@Resource private MybatisPlusInterceptor interceptor;
@Resource private DataSource dataSource;
@MockBean private TradeConfigService tradeConfigService;
@MockBean private BrokerageUserService brokerageUserService;
@MockBean private ProductSpuApi productSpuApi;
@MockBean private ProductSkuApi productSkuApi;
private JdbcTemplate jdbc;
@BeforeEach
void prepareTenantQueries() {
jdbc = new JdbcTemplate(dataSource);
interceptor.setInterceptors(Arrays.asList(
new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(new TenantProperties())),
new PaginationInnerInterceptor()));
TenantContextHolder.setTenantId(1L);
}
@AfterEach
void clearTenant() {
TenantContextHolder.clear();
}
@Test
void defaultsAndEmptyInputsReturnZeroSummaries() {
assertThat(new BrokerageIncomeSummaryRespDTO().getSettledPrice()).isZero();
assertThat(new BrokerageIncomeSummaryRespDTO().getPendingPrice()).isZero();
assertThat(service.getIncomeSummaryMap(Collections.emptyList())).isEmpty();
assertThat(service.getContributionSummaryMap(null, Collections.emptyList())).isEmpty();
assertThat(service.getIncomeSummaryMap(Arrays.asList(1L, 2L)))
.hasSize(2).allSatisfy((id, summary) -> {
assertThat(summary.getSettledPrice()).isZero();
assertThat(summary.getPendingPrice()).isZero();
});
assertThatNullPointerException().isThrownBy(() ->
service.getContributionSummaryMap(null, Collections.singletonList(2L)));
}
@Test
void cumulativeIncomeUsesLongAndSeparatesPendingFromCancelled() {
add(1, 2, 1, 1, Integer.MAX_VALUE, "订单佣金", "获得推广佣金", false, 1);
add(1, 3, 2, 1, Integer.MAX_VALUE, "会员佣金", "获得会员佣金", false, 1);
add(1, 2, 2, 0, 500, "会员佣金", "获得会员佣金", false, 1);
add(1, 2, 1, 2, 700, "订单佣金", "获得推广佣金", false, 1);
// 缺少来源会员的历史收益仍计入收款人的累计收益。
add(1, 0, 1, 1, 100, null, null, false, 1);
BrokerageIncomeSummaryRespDTO summary = service.getIncomeSummaryMap(Collections.singletonList(1L)).get(1L);
assertThat(summary.getSettledPrice()).isEqualTo(2L * Integer.MAX_VALUE + 100);
assertThat(summary.getPendingPrice()).isEqualTo(500);
}
@Test
void currentAndHistoricalWithdrawalRecordsAreExcluded() {
add(1, 2, 1, 1, 100, "订单佣金", "获得推广佣金", false, 1);
add(1, 2, 2, 1, 200, "会员佣金", "获得会员佣金", false, 1);
add(1, 0, 2, 1, -50, "提现申请", "提现申请扣除佣金", false, 1);
add(1, 0, 3, 1, 50, "提现申请驳回", "返还佣金", false, 1);
add(1, 0, 4, 1, 50, "提现申请驳回", "返还佣金", false, 1);
add(1, 0, 2, 1, 60, "历史提现返还", "返还佣金", false, 1);
add(1, 0, 1, 1, 70, "历史记录", "提现申请驳回,返还佣金", false, 1);
add(1, 0, 0, 1, 80, "其他业务", "其他业务", false, 1);
add(1, 2, 1, 1, 0, "订单佣金", "获得推广佣金", false, 1);
BrokerageIncomeSummaryRespDTO summary = service.getIncomeSummaryMap(Collections.singletonList(1L)).get(1L);
assertThat(summary.getSettledPrice()).isEqualTo(300);
BrokerageRecordPageReqVO query = incomeQuery(1L, null);
assertThat(mapper.selectPage(query).getTotal()).isEqualTo(2);
Long aliasTotal = jdbc.queryForObject("SELECT SUM(r.price) FROM trade_brokerage_record r WHERE "
+ BrokerageIncomeSql.condition("r"), Long.class);
assertThat(aliasTotal).isEqualTo(300);
}
@Test
void contributionUsesStoredRecipientAndExactSourceMember() {
add(1, 2, 1, 1, 100, "订单佣金", "获得推广佣金", false, 1);
add(1, 2, 2, 0, 50, "会员佣金", "获得会员佣金", false, 1);
add(1, 3, 2, 1, 200, "会员佣金", "获得会员佣金", false, 1);
add(2, 3, 2, 1, 300, "会员佣金", "获得会员佣金", false, 1);
add(4, 2, 1, 1, 400, "订单佣金", "获得推广佣金", false, 1);
Map<Long, BrokerageIncomeSummaryRespDTO> summaries = service.getContributionSummaryMap(1L,
Arrays.asList(2L, 3L, 9L));
assertThat(summaries.get(2L).getSettledPrice()).isEqualTo(100);
assertThat(summaries.get(2L).getPendingPrice()).isEqualTo(50);
assertThat(summaries.get(3L).getSettledPrice()).isEqualTo(200);
assertThat(summaries.get(9L).getSettledPrice()).isZero();
assertThat(service.getIncomeSummaryMap(Collections.singletonList(2L)).get(2L).getSettledPrice())
.isEqualTo(300);
assertThat(mapper.selectPage(incomeQuery(1L, 2L)).getList())
.hasSize(2).allSatisfy(record -> {
assertThat(record.getUserId()).isEqualTo(1L);
assertThat(record.getSourceUserId()).isEqualTo(2L);
});
}
@Test
void aggregateAndDetailQueriesRespectTenantAndLogicalDeletion() {
add(1, 2, 1, 1, 100, "订单佣金", "获得推广佣金", false, 1);
add(1, 2, 1, 1, 200, "订单佣金", "获得推广佣金", true, 1);
add(1, 2, 1, 1, 400, "订单佣金", "获得推广佣金", false, 2);
assertThat(service.getIncomeSummaryMap(Collections.singletonList(1L)).get(1L).getSettledPrice())
.isEqualTo(100);
assertThat(service.getContributionSummaryMap(1L, Collections.singletonList(2L)).get(2L).getSettledPrice())
.isEqualTo(100);
assertThat(mapper.selectPage(incomeQuery(1L, 2L)).getTotal()).isEqualTo(1);
TenantContextHolder.setTenantId(2L);
assertThat(service.getIncomeSummaryMap(Collections.singletonList(1L)).get(1L).getSettledPrice())
.isEqualTo(400);
}
@Test
void incomePaginationReturnsRecordsBeyondTwoThousand() {
List<Object[]> rows = new ArrayList<>();
for (int index = 0; index < 2105; index++) {
rows.add(new Object[]{1, 2, 1, 1, 10, "订单佣金", "获得推广佣金", false, 1});
}
jdbc.batchUpdate(insertSql(), rows);
add(1, 2, 3, 1, 100, "提现申请", "提现申请扣除佣金", false, 1);
add(1, 3, 1, 1, 100, "其他来源", "获得推广佣金", false, 1);
BrokerageRecordPageReqVO query = incomeQuery(1L, 2L);
query.setPageSize(100);
query.setPageNo(22);
PageResult<BrokerageRecordDO> page = mapper.selectPage(query);
assertThat(page.getTotal()).isEqualTo(2105);
assertThat(page.getList()).hasSize(5);
assertThat(page.getList()).extracting(BrokerageRecordDO::getId).isSortedAccordingTo(Collections.reverseOrder());
query.setPageNo(1);
query.setIncomeOnly(null);
assertThat(mapper.selectPage(query).getTotal()).isEqualTo(2106);
query.setIncomeOnly(false);
assertThat(mapper.selectPage(query).getTotal()).isEqualTo(2106);
}
private BrokerageRecordPageReqVO incomeQuery(Long userId, Long sourceUserId) {
BrokerageRecordPageReqVO query = new BrokerageRecordPageReqVO();
query.setUserId(userId);
query.setSourceUserId(sourceUserId);
query.setIncomeOnly(true);
return query;
}
@Test
void orderSummaryAndDetailsUseTheExactBusinessAndActualRecipientsAcrossAllStatuses() {
addOrder("100", 2, 10, 1, 1, 200, false, 1, "会员佣金");
addOrder("100", 2, 10, 1, 1, 50, false, 1, "会员佣金");
addOrder("100", 2, 20, 2, 0, 100, false, 1, "会员佣金");
addOrder("100", 2, 30, 1, 2, 300, false, 1, "历史收益人");
addOrder("101", 2, 10, 1, 1, 400, false, 1, "其他开通单");
addOrder("100", 1, 10, 1, 1, 999, false, 1, "同编号的商城订单");
addOrder("100", 2, 10, 1, 1, 500, false, 1, "历史提现返还");
addOrder("100", 2, 10, 1, 1, 600, true, 1, "已删除佣金");
addOrder("100", 2, 10, 1, 1, 700, false, 2, "其他租户");
addOrder("100", 2, 10, 1, 1, 0, false, 1, "零金额");
addOrder("100", 2, 10, 1, 1, -100, false, 1, "负金额");
Map<String, BrokerageOrderSummaryRespDTO> summaries = service.getOrderSummaryMap(
BrokerageRecordBizTypeEnum.OPEN_TYPE, Arrays.asList("100", "101", "999"));
BrokerageOrderSummaryRespDTO summary = summaries.get("100");
assertThat(summary.getSettledPrice()).isEqualTo(250);
assertThat(summary.getPendingPrice()).isEqualTo(100);
assertThat(summary.getCancelledPrice()).isEqualTo(300);
assertThat(summary.getRecipients()).extracting(BrokerageOrderSummaryRespDTO.Recipient::getUserId)
.containsExactly(10L, 30L, 20L);
assertThat(summary.getRecipients().get(0).getSettledPrice()).isEqualTo(250);
assertThat(summary.getRecipients().get(1).getCancelledPrice()).isEqualTo(300);
assertThat(summary.getRecipients().get(2).getSourceUserLevel()).isEqualTo(2);
assertThat(summaries.get("101").getSettledPrice()).isEqualTo(400);
assertThat(summaries.get("999").getRecipients()).isEmpty();
assertThat(summaries.get("999").getSettledPrice()).isZero();
BrokerageRecordPageReqVO query = incomeQuery(null, null);
query.setBizType(2);
query.setBizId("100");
assertThat(mapper.selectPage(query).getTotal()).isEqualTo(4);
query.setStatus(1);
assertThat(mapper.selectPage(query).getList()).extracting(BrokerageRecordDO::getPrice).containsExactly(50, 200);
query.setStatus(0);
assertThat(mapper.selectPage(query).getList()).singleElement().satisfies(record -> assertThat(record.getPrice()).isEqualTo(100));
query.setStatus(2);
assertThat(mapper.selectPage(query).getList()).singleElement().satisfies(record -> assertThat(record.getPrice()).isEqualTo(300));
TenantContextHolder.setTenantId(2L);
assertThat(service.getOrderSummaryMap(BrokerageRecordBizTypeEnum.OPEN_TYPE, Collections.singletonList("100"))
.get("100").getSettledPrice()).isEqualTo(700);
}
@Test
void orderSummarySupportsLongAmountsUnknownLevelsAndEmptyInputs() {
assertThat(service.getOrderSummaryMap(null, Collections.emptyList())).isEmpty();
assertThatNullPointerException().isThrownBy(() -> service.getOrderSummaryMap(null, Collections.singletonList("100")));
addOrder("100", 2, 10, 0, 1, Integer.MAX_VALUE, false, 1, "会员佣金");
addOrder("100", 2, 10, 0, 1, Integer.MAX_VALUE, false, 1, "会员佣金");
BrokerageOrderSummaryRespDTO summary = service.getOrderSummaryMap(BrokerageRecordBizTypeEnum.OPEN_TYPE,
Collections.singletonList("100")).get("100");
assertThat(summary.getSettledPrice()).isEqualTo(2L * Integer.MAX_VALUE);
assertThat(summary.getPendingPrice()).isZero();
assertThat(summary.getCancelledPrice()).isZero();
assertThat(summary.getRecipients()).singleElement().satisfies(recipient -> {
assertThat(recipient.getSourceUserLevel()).isZero();
assertThat(recipient.getSettledPrice()).isEqualTo(2L * Integer.MAX_VALUE);
});
}
private void addOrder(String bizId, int bizType, long userId, int level, int status, int price,
boolean deleted, long tenantId, String title) {
jdbc.update("INSERT INTO trade_brokerage_record "
+ "(biz_id, biz_type, user_id, source_user_level, status, price, deleted, tenant_id, title) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
bizId, bizType, userId, level, status, price, deleted, tenantId, title);
}
private void add(long userId, long sourceUserId, int bizType, int status, int price,
String title, String description, boolean deleted, long tenantId) {
jdbc.update(insertSql(), userId, sourceUserId, bizType, status, price, title, description, deleted, tenantId);
}
private String insertSql() {
return "INSERT INTO trade_brokerage_record "
+ "(user_id, source_user_id, biz_type, status, price, title, description, deleted, tenant_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
}
}
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "trade_brokerage_record" (
"id" int NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"user_id" bigint NOT NULL,
"biz_id" varchar DEFAULT '',
"biz_type" int NOT NULL,
"title" varchar DEFAULT '',
"description" varchar DEFAULT '',
"price" int NOT NULL,
"total_price" int DEFAULT 0,
"status" int NOT NULL,
"frozen_days" int DEFAULT 0,
"unfreeze_time" datetime,
"source_user_level" int DEFAULT 0,
"source_user_id" bigint DEFAULT 0,
"creator" varchar DEFAULT '',
"create_time" datetime DEFAULT CURRENT_TIMESTAMP,
"updater" varchar DEFAULT '',
"update_time" datetime DEFAULT CURRENT_TIMESTAMP,
"deleted" bit DEFAULT FALSE,
"tenant_id" bigint DEFAULT 0
);
@@ -8,16 +8,27 @@ import cn.iocoder.yudao.framework.idempotent.core.keyresolver.impl.UserIdempoten
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserAdminOpenReqVo;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserExportRespVO;
import cn.iocoder.yudao.module.member.dal.dataobject.opentype.OpenTypeUserDO;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService;
import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.validation.annotation.Validated;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -32,6 +43,9 @@ public class OpenTypeUserController {
@Resource
private OpenTypeService openTypeService;
@Resource
private MemberUserExportService memberUserExportService;
@GetMapping("/last-by-user-id")
@Operation(summary = "获取最后一条会员开通记录")
public CommonResult<OpenTypeUserDO> lastByUserId(@RequestParam("userId") Long userId) {
@@ -50,6 +64,31 @@ public class OpenTypeUserController {
return success(openTypeService.getOpenTypeUserPage(pageReqVO));
}
@PostMapping("/export-async")
@Operation(summary = "按会员开通记录搜索条件提交分批导出任务")
public CommonResult<MemberUserExportRespVO> exportHistory(@Valid OpenTypeUserReqVO query) {
return success(memberUserExportService.createHistory(query));
}
@GetMapping("/export-progress")
@Operation(summary = "查询当前登录人的会员开通记录导出进度")
public CommonResult<MemberUserExportRespVO> getExportProgress(@RequestParam("taskId") String taskId) {
return success(memberUserExportService.getHistoryProgress(taskId));
}
@GetMapping("/export-download")
@Operation(summary = "下载当前登录人的会员开通记录导出文件")
public ResponseEntity<FileSystemResource> downloadExport(@RequestParam("taskId") String taskId) throws IOException {
Path file = memberUserExportService.getHistoryDownloadFile(taskId);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename("会员开通记录.xlsx", StandardCharsets.UTF_8).build().toString())
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(Files.size(file))
.body(new FileSystemResource(file));
}
@GetMapping("/refund")
@Operation(summary = "退款")
@Idempotent(timeout = 3, timeUnit = TimeUnit.SECONDS, keyResolver = UserIdempotentKeyResolver.class, message = "正在退款中,请稍后重试")
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.ArrayList;
import java.util.List;
/** 会员开通单的佣金及实际收益人,金额单位为分。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class OpenTypeUserBrokerageRespVO extends BrokerageIncomeSummaryRespDTO {
private Long cancelledPrice = 0L;
private List<Recipient> recipients = new ArrayList<>();
@Data
@EqualsAndHashCode(callSuper = true)
public static class Recipient extends BrokerageOrderSummaryRespDTO.Recipient {
private String userCode;
private String nickname;
private String avatar;
private boolean memberExists;
}
}
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.format.NumberFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@ExcelIgnoreUnannotated
@ColumnWidth(22)
public class OpenTypeUserExportRowVO {
@ExcelProperty("记录编号")
private String id;
@ExcelProperty("用户ID")
private String memberUserCode;
@ExcelProperty("用户名")
private String memberNickname;
@ExcelProperty("手机号")
private String memberMobile;
@ExcelProperty("会员标题")
private String vipTitle;
@ExcelProperty("会员时长(天)")
private String vipDuration;
@ExcelProperty("金额(元)")
private BigDecimal payPrice;
@ExcelProperty("支付状态")
private String payStatus;
@ExcelProperty("充值时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime payTime;
@ExcelProperty("支付订单编号")
private String payOrderId;
@ExcelProperty("支付渠道")
private String payChannelCode;
@ExcelProperty("退款状态")
private String refundStatus;
@ExcelProperty("退款金额(元)")
private BigDecimal refundPayPrice;
@ExcelProperty("退款时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime refundTime;
@ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@ExcelProperty("已结算佣金(元)")
@NumberFormat("0.00")
private BigDecimal brokerageSettledPrice;
@ExcelProperty("待结算佣金(元)")
@NumberFormat("0.00")
private BigDecimal brokeragePendingPrice;
@ExcelProperty("已取消佣金(元)")
@NumberFormat("0.00")
private BigDecimal brokerageCancelledPrice;
@ExcelProperty("分销明细")
@ColumnWidth(200)
private String brokerageDetails;
}
@@ -3,10 +3,14 @@ package cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Data
public class OpenTypeUserReqVO extends PageParam {
@@ -25,7 +29,29 @@ public class OpenTypeUserReqVO extends PageParam {
@Schema(description = "会员名称")
private String memberName;
@Schema(description = "用户 ID,App 我的页面展示的 ID")
private String userCode;
@Schema(description = "手机号,支持模糊查询")
private String memberMobile;
@Schema(description = "会员标题,支持模糊查询")
private String title;
@Schema(description = "充值时间,按支付成功时间筛选")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] payTime;
@Schema(description = "退款时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] refundTime;
@Schema(description = "创建时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
@Schema(description = "创建时间")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private List<LocalDate> createTimeList;
}
@@ -53,6 +53,9 @@ public class OpenTypeUserRespVO {
@Schema(description = "退款时间")
private LocalDateTime refundTime;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "退款状态")
private Integer refundStatus;
@@ -61,6 +64,8 @@ public class OpenTypeUserRespVO {
*/
private String memberNickname;
private String memberUserCode;
private String memberAvatar;
private String memberMobile;
@@ -71,4 +76,7 @@ public class OpenTypeUserRespVO {
private String vipTitle;
private String vipDuration;
@Schema(description = "本单实际产生的分销佣金及收益人")
private OpenTypeUserBrokerageRespVO brokerageSummary = new OpenTypeUserBrokerageRespVO();
}
@@ -25,6 +25,8 @@ import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageUserApi;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -48,6 +50,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -86,6 +89,8 @@ public class MemberUserController {
private AddressService addressService;
@Resource
private TradeBrokerageUserApi tradeBrokerageUserApi;
@Resource
private TradeBrokerageRecordApi tradeBrokerageRecordApi;
@PutMapping("/update")
@Operation(summary = "更新会员用户")
@@ -138,6 +143,7 @@ public class MemberUserController {
// 查询用户tag
if (ObjectUtil.isNotEmpty(result)) {
fillInviters(Collections.singletonList(result));
fillDetailBrokerageSummaries(result);
List<MemberRecentTagDO> memberRecentTags = memberRecentTagMapper.getByMemberId(result.getId());
if (ObjectUtil.isNotEmpty(memberRecentTags)) {
List<MemberUserRespVO.MemberTag> tags = memberRecentTags.stream().map(v -> {
@@ -158,7 +164,7 @@ public class MemberUserController {
public CommonResult<PageResult<MemberUserRespVO>> getUserPage(@Valid MemberUserPageReqVO pageVO) {
PageResult<MemberUserDO> pageResult = memberUserService.getUserPage(pageVO);
if (CollUtil.isEmpty(pageResult.getList())) {
return success(PageResult.empty());
return success(new PageResult<>(Collections.emptyList(), pageResult.getTotal()));
}
// 处理用户标签返显
Set<Long> tagIds = pageResult.getList().stream()
@@ -180,6 +186,10 @@ public class MemberUserController {
PageResult<MemberUserRespVO> result = MemberUserConvert.INSTANCE.convertPage(
pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses);
fillInviters(result.getList());
Map<Long, BrokerageIncomeSummaryRespDTO> summaries = tradeBrokerageRecordApi.getIncomeSummaryMap(
convertSet(result.getList(), MemberUserRespVO::getId));
result.getList().forEach(user -> user.setBrokerageSummary(
summaries.getOrDefault(user.getId(), new BrokerageIncomeSummaryRespDTO())));
return success(result);
}
@@ -196,7 +206,15 @@ public class MemberUserController {
Map<Long, MemberUserDO> users = convertMap(memberUserService.getUserList(
convertSet(page.getList(), BrokerageUserRespDTO::getId)), MemberUserDO::getId);
PageResult<MemberUserBrokerageChildRespVO> result = BeanUtils.toBean(page, MemberUserBrokerageChildRespVO.class);
Set<Long> childIds = convertSet(page.getList(), BrokerageUserRespDTO::getId);
Map<Long, BrokerageIncomeSummaryRespDTO> summaries = tradeBrokerageRecordApi.getIncomeSummaryMap(childIds);
Long contributionUserId = reqVO.getContributionUserId() == null
? reqVO.getBindUserId() : reqVO.getContributionUserId();
Map<Long, BrokerageIncomeSummaryRespDTO> contributions = tradeBrokerageRecordApi.getContributionSummaryMap(
contributionUserId, childIds);
result.getList().forEach(child -> {
child.setBrokerageSummary(summaries.getOrDefault(child.getId(), new BrokerageIncomeSummaryRespDTO()));
child.setContributionSummary(contributions.getOrDefault(child.getId(), new BrokerageIncomeSummaryRespDTO()));
MemberUserDO user = users.get(child.getId());
child.setMemberExists(user != null);
if (user != null) {
@@ -209,6 +227,22 @@ public class MemberUserController {
return success(result);
}
private void fillDetailBrokerageSummaries(MemberUserRespVO user) {
Set<Long> userIds = new HashSet<>(Collections.singleton(user.getId()));
MemberUserInviterRespVO inviter = user.getInviter();
if (inviter != null) {
userIds.add(inviter.getId());
}
Map<Long, BrokerageIncomeSummaryRespDTO> summaries = tradeBrokerageRecordApi.getIncomeSummaryMap(userIds);
user.setBrokerageSummary(summaries.getOrDefault(user.getId(), new BrokerageIncomeSummaryRespDTO()));
if (inviter != null) {
inviter.setBrokerageSummary(summaries.getOrDefault(inviter.getId(), new BrokerageIncomeSummaryRespDTO()));
Map<Long, BrokerageIncomeSummaryRespDTO> contributions = tradeBrokerageRecordApi.getContributionSummaryMap(
user.getId(), Collections.singleton(inviter.getId()));
inviter.setContributionSummary(contributions.getOrDefault(inviter.getId(), new BrokerageIncomeSummaryRespDTO()));
}
}
private void fillInviters(List<MemberUserRespVO> users) {
List<BrokerageUserRespDTO> relations = tradeBrokerageUserApi.getBrokerageUserList(
convertSet(users, MemberUserRespVO::getId));
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -29,4 +30,10 @@ public class MemberUserBrokerageChildRespVO {
@Schema(description = "会员资料是否存在")
private Boolean memberExists;
@Schema(description = "该会员的分销收益汇总,金额单位为分")
private BrokerageIncomeSummaryRespDTO brokerageSummary = new BrokerageIncomeSummaryRespDTO();
@Schema(description = "该会员贡献给指定会员的收益汇总,金额单位为分")
private BrokerageIncomeSummaryRespDTO contributionSummary = new BrokerageIncomeSummaryRespDTO();
}
@@ -17,6 +17,10 @@ public class MemberUserBrokerageChildrenPageReqVO extends PageParam {
@Positive(message = "邀请人编号必须为正数")
private Long bindUserId;
@Schema(description = "收益贡献的接收会员编号,未传时使用邀请人编号")
@Positive(message = "收益接收会员编号必须为正数")
private Long contributionUserId;
public MemberUserBrokerageChildrenPageReqVO() {
setPageSize(20);
}
@@ -4,10 +4,6 @@ import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.ContentStyle;
import com.alibaba.excel.enums.poi.VerticalAlignmentEnum;
import lombok.Data;
import java.time.LocalDateTime;
@@ -15,9 +11,6 @@ import java.time.LocalDateTime;
@Data
@ExcelIgnoreUnannotated
@ColumnWidth(22)
@HeadRowHeight(25)
@ContentRowHeight(24)
@ContentStyle(verticalAlignment = VerticalAlignmentEnum.CENTER)
public class MemberUserExportRowVO {
@ExcelProperty("用户ID")
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -25,4 +26,10 @@ public class MemberUserInviterRespVO {
@Schema(description = "邀请人的会员资料是否存在")
private Boolean memberExists;
@Schema(description = "邀请人的分销收益汇总,金额单位为分")
private BrokerageIncomeSummaryRespDTO brokerageSummary = new BrokerageIncomeSummaryRespDTO();
@Schema(description = "邀请人贡献给当前会员的收益汇总,金额单位为分")
private BrokerageIncomeSummaryRespDTO contributionSummary = new BrokerageIncomeSummaryRespDTO();
}
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -9,6 +10,8 @@ import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Min;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@@ -59,6 +62,21 @@ public class MemberUserPageReqVO extends PageParam {
@Schema(description = "是否禁用:0 未禁用,1 已禁用", example = "1")
private Integer status;
@Schema(description = "累计已结算分销收益下限,单位为分,包含边界", example = "100")
@Min(value = 0, message = "分销收益下限不能小于零")
private Long brokeragePriceMin;
@Schema(description = "累计已结算分销收益上限,单位为分,包含边界", example = "10000")
@Min(value = 0, message = "分销收益上限不能小于零")
private Long brokeragePriceMax;
@JsonIgnore
@AssertTrue(message = "分销收益下限不能大于上限")
public boolean isBrokeragePriceRangeValid() {
return brokeragePriceMin == null || brokeragePriceMax == null
|| brokeragePriceMin <= brokeragePriceMax;
}
// TODO 芋艿:注册用户类型;
// TODO 芋艿:登录用户类型;
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.member.controller.admin.user.vo;
import cn.iocoder.yudao.module.member.controller.admin.recent.vo.RecentPageResVo;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentCfgDO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -25,6 +26,9 @@ public class MemberUserRespVO extends MemberUserBaseVO {
@Schema(description = "直接邀请人,未绑定时为空")
private MemberUserInviterRespVO inviter;
@Schema(description = "分销收益汇总,金额单位为分")
private BrokerageIncomeSummaryRespDTO brokerageSummary = new BrokerageIncomeSummaryRespDTO();
@Schema(description = "微信号", example = "wx_user")
private String wechatNum;
@@ -46,4 +46,14 @@ public interface OpenTypeUserMapper extends BaseMapperX<OpenTypeUserDO> {
* 获得会员开通记录分页
*/
IPage<OpenTypeUserRespVO> page(IPage<Page> page, @Param("dto") OpenTypeUserReqVO dto);
}
Long selectExportUpperBound(@Param("dto") OpenTypeUserReqVO dto);
long selectExportCount(@Param("dto") OpenTypeUserReqVO dto, @Param("upperId") Long upperId);
List<OpenTypeUserRespVO> selectExportBatch(@Param("dto") OpenTypeUserReqVO dto,
@Param("upperId") Long upperId,
@Param("lastCreateTime") LocalDateTime lastCreateTime,
@Param("lastId") Long lastId,
@Param("limit") int limit);
}
@@ -10,6 +10,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageIncomeSql;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
@@ -129,6 +131,16 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
.eqIfPresent(MemberUserDO::getSex, reqVO.getSex())
.eqIfPresent(MemberUserDO::getStatus, reqVO.getStatus())
.apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql);
if (reqVO.getBrokeragePriceMin() != null || reqVO.getBrokeragePriceMax() != null) {
// COALESCE 放在子查询内,保证租户拦截器能够解析并隔离收益记录。
String incomeSql = "(SELECT COALESCE(SUM(income.price), 0) FROM trade_brokerage_record income"
+ " WHERE income.user_id = member_user.id AND income.deleted = 0 AND "
+ BrokerageIncomeSql.condition("income") + " AND income.status = {0})";
query.apply(reqVO.getBrokeragePriceMin() != null, incomeSql + " >= {1}",
BrokerageRecordStatusEnum.SETTLEMENT.getStatus(), reqVO.getBrokeragePriceMin());
query.apply(reqVO.getBrokeragePriceMax() != null, incomeSql + " <= {1}",
BrokerageRecordStatusEnum.SETTLEMENT.getStatus(), reqVO.getBrokeragePriceMax());
}
if (CollUtil.isNotEmpty(reqVO.getMemberRecentTagList())) {
String placeholders = IntStream.range(0, reqVO.getMemberRecentTagList().size())
.mapToObj(index -> "{" + index + "}")
@@ -27,7 +27,7 @@ public class MemberUserExportRedisDAO {
stringRedisTemplate.opsForValue().set(taskKey(task.getTenantId(), task.getUserId(), task.getTaskId()),
JsonUtils.toJsonString(task), Duration.ofHours(properties.getRetentionHours()));
// 每批续期,长时间导出不会使防重复提交锁提前失效。
stringRedisTemplate.expire(activeKey(task.getTenantId(), task.getUserId()), Duration.ofHours(properties.getRetentionHours()));
stringRedisTemplate.expire(activeKey(task.getTenantId(), task.getUserId(), task.getExportType()), Duration.ofHours(properties.getRetentionHours()));
}
public MemberUserExportTask get(Long tenantId, Long userId, String taskId) {
@@ -35,17 +35,17 @@ public class MemberUserExportRedisDAO {
MemberUserExportTask.class);
}
public boolean acquire(Long tenantId, Long userId, String taskId) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(activeKey(tenantId, userId), taskId,
public boolean acquire(Long tenantId, Long userId, String taskId, String exportType) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(activeKey(tenantId, userId, exportType), taskId,
Duration.ofHours(properties.getRetentionHours())));
}
public String getActiveTaskId(Long tenantId, Long userId) {
return stringRedisTemplate.opsForValue().get(activeKey(tenantId, userId));
public String getActiveTaskId(Long tenantId, Long userId, String exportType) {
return stringRedisTemplate.opsForValue().get(activeKey(tenantId, userId, exportType));
}
public void release(MemberUserExportTask task) {
stringRedisTemplate.execute(RELEASE_SCRIPT, Collections.singletonList(activeKey(task.getTenantId(), task.getUserId())),
stringRedisTemplate.execute(RELEASE_SCRIPT, Collections.singletonList(activeKey(task.getTenantId(), task.getUserId(), task.getExportType())),
task.getTaskId());
}
@@ -61,7 +61,8 @@ public class MemberUserExportRedisDAO {
return "member:user:export:task:" + tenantId + ":" + userId + ":" + taskId;
}
private String activeKey(Long tenantId, Long userId) {
return "member:user:export:active:" + tenantId + ":" + userId;
private String activeKey(Long tenantId, Long userId, String exportType) {
String key = "member:user:export:active:" + tenantId + ":" + userId;
return exportType == null ? key : key + ":" + exportType;
}
}
@@ -60,6 +60,9 @@ public interface OpenTypeService {
*/
PageResult<OpenTypeUserRespVO> getOpenTypeUserPage(OpenTypeUserReqVO pageReqVO);
/** 批量填充开通单的实际分销收益,供列表和分批导出共用。 */
void fillBrokerageSummary(List<OpenTypeUserRespVO> records);
/**
* 获得会员类列表
*/
@@ -111,4 +114,4 @@ public interface OpenTypeService {
* 退款的回调
*/
void updateWalletRechargeRefunded(Long merchantOrderId, Long payRefundId);
}
}
@@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateTotal
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openType.OpenTypePageReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openType.OpenTypeSaveReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserAdminOpenReqVo;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserBrokerageRespVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO;
import cn.iocoder.yudao.module.member.controller.app.opentype.vo.AppPayOpenReqType;
@@ -35,9 +36,7 @@ import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -60,10 +59,16 @@ import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.hutool.core.util.ObjectUtil.notEqual;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime;
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
import static cn.iocoder.yudao.framework.common.util.number.MoneyUtils.fenToYuanStr;
@@ -197,6 +202,7 @@ public class OpenTypeServiceImpl implements OpenTypeService {
public PageResult<OpenTypeUserRespVO> getOpenTypeUserPage(OpenTypeUserReqVO pageReqVO) {
IPage<OpenTypeUserRespVO> pageList = openTypeUserMapper.page(new Page(pageReqVO.getPageNo(), pageReqVO.getPageSize()), pageReqVO);
List<OpenTypeUserRespVO> list = pageList.getRecords();
fillBrokerageSummary(list);
PageResult<OpenTypeUserRespVO> result = new PageResult<OpenTypeUserRespVO>();
result.setList(list);
@@ -204,6 +210,39 @@ public class OpenTypeServiceImpl implements OpenTypeService {
return result;
}
@Override
public void fillBrokerageSummary(List<OpenTypeUserRespVO> records) {
if (records.isEmpty()) {
return;
}
List<String> bizIds = records.stream().map(record -> record.getId().toString()).collect(Collectors.toList());
Map<String, BrokerageOrderSummaryRespDTO> summaries = tradeBrokerageRecordApi.getOrderSummaryMap(
BrokerageRecordBizTypeEnum.OPEN_TYPE, bizIds);
Set<Long> recipientIds = summaries.values().stream().flatMap(summary -> summary.getRecipients().stream())
.map(BrokerageOrderSummaryRespDTO.Recipient::getUserId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, MemberUserDO> members = recipientIds.isEmpty() ? Collections.emptyMap()
: convertMap(memberUserService.getUserList(recipientIds), MemberUserDO::getId);
for (OpenTypeUserRespVO record : records) {
BrokerageOrderSummaryRespDTO source = summaries.getOrDefault(record.getId().toString(), new BrokerageOrderSummaryRespDTO());
OpenTypeUserBrokerageRespVO summary = new OpenTypeUserBrokerageRespVO();
summary.setSettledPrice(source.getSettledPrice());
summary.setPendingPrice(source.getPendingPrice());
summary.setCancelledPrice(source.getCancelledPrice());
summary.setRecipients(source.getRecipients().stream().map(recipient -> {
OpenTypeUserBrokerageRespVO.Recipient result = BeanUtils.toBean(recipient, OpenTypeUserBrokerageRespVO.Recipient.class);
MemberUserDO member = members.get(recipient.getUserId());
result.setMemberExists(member != null);
if (member != null) {
result.setUserCode(member.getUserCode());
result.setNickname(member.getNickname());
result.setAvatar(member.getAvatar());
}
return result;
}).collect(Collectors.toList()));
record.setBrokerageSummary(summary);
}
}
/**
* 获得会员类列表
*/
@@ -569,4 +608,4 @@ public class OpenTypeServiceImpl implements OpenTypeService {
throw exception(MemberErrorCode.OPEN_TYPE_NOT_EXISTS);
}
}
}
}
@@ -0,0 +1,122 @@
package cn.iocoder.yudao.module.member.service.opentype;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserBrokerageRespVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserExportRowVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO;
import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeUserMapper;
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
@Component
public class OpenTypeUserExportWriter {
@Resource
private OpenTypeUserMapper openTypeUserMapper;
@Resource
private MemberUserExportProperties properties;
@Resource
private OpenTypeService openTypeService;
public long write(Path file, OpenTypeUserReqVO query, BiConsumer<Long, Long> progress) {
Long upperId = openTypeUserMapper.selectExportUpperBound(query);
long total = upperId == null ? 0 : openTypeUserMapper.selectExportCount(query, upperId);
progress.accept(total, 0L);
long processed = 0;
Long lastId = null;
LocalDateTime lastCreateTime = null;
int sheetIndex = 0;
int sheetRows = 0;
try (ExcelWriter writer = ExcelUtils.writeBuilder(OpenTypeUserExportRowVO.class)
.file(file.toFile()).inMemory(false).build()) {
WriteSheet sheet = EasyExcel.writerSheet(sheetIndex, "会员开通记录1").build();
writer.write(Collections.emptyList(), sheet);
while (upperId != null) {
if (Thread.currentThread().isInterrupted()) {
throw new IllegalStateException("导出线程已中断");
}
List<OpenTypeUserRespVO> records = openTypeUserMapper.selectExportBatch(query, upperId,
lastCreateTime, lastId, properties.getBatchSize());
if (records.isEmpty()) {
break;
}
openTypeService.fillBrokerageSummary(records);
List<OpenTypeUserExportRowVO> rows = records.stream().map(this::buildRow).collect(Collectors.toList());
int offset = 0;
while (offset < rows.size()) {
if (sheetRows == properties.getRowsPerSheet()) {
sheetIndex++;
sheet = EasyExcel.writerSheet(sheetIndex, "会员开通记录" + (sheetIndex + 1)).build();
sheetRows = 0;
}
int end = Math.min(rows.size(), offset + properties.getRowsPerSheet() - sheetRows);
writer.write(rows.subList(offset, end), sheet);
sheetRows += end - offset;
offset = end;
}
OpenTypeUserRespVO last = records.get(records.size() - 1);
lastId = last.getId();
lastCreateTime = last.getCreateTime();
processed += records.size();
progress.accept(Math.max(total, processed), processed);
}
}
return processed;
}
private OpenTypeUserExportRowVO buildRow(OpenTypeUserRespVO record) {
OpenTypeUserExportRowVO row = new OpenTypeUserExportRowVO();
row.setId(record.getId().toString());
row.setMemberUserCode(record.getMemberUserCode());
row.setMemberNickname(record.getMemberNickname());
row.setMemberMobile(record.getMemberMobile());
row.setVipTitle(record.getVipTitle());
row.setVipDuration(record.getVipDuration());
row.setPayPrice(record.getPayPrice() == null ? null : BigDecimal.valueOf(record.getPayPrice(), 2));
row.setPayStatus(record.getPayTime() != null || Boolean.TRUE.equals(record.getPayStatus()) ? "支付成功" : "未支付");
row.setPayTime(record.getPayTime());
row.setPayOrderId(record.getPayOrderId() == null ? null : record.getPayOrderId().toString());
row.setPayChannelCode(record.getPayChannelCode());
row.setRefundStatus(Arrays.stream(PayRefundStatusEnum.values())
.filter(status -> Objects.equals(status.getStatus(), record.getRefundStatus()))
.map(PayRefundStatusEnum::getName)
.findFirst().orElse(record.getRefundStatus() == null ? "未退款" : record.getRefundStatus().toString()));
row.setRefundPayPrice(record.getRefundPayPrice() == null ? null : BigDecimal.valueOf(record.getRefundPayPrice(), 2));
row.setRefundTime(record.getRefundTime());
row.setCreateTime(record.getCreateTime());
OpenTypeUserBrokerageRespVO summary = record.getBrokerageSummary();
row.setBrokerageSettledPrice(BigDecimal.valueOf(summary.getSettledPrice(), 2));
row.setBrokeragePendingPrice(BigDecimal.valueOf(summary.getPendingPrice(), 2));
row.setBrokerageCancelledPrice(BigDecimal.valueOf(summary.getCancelledPrice(), 2));
row.setBrokerageDetails(summary.getRecipients().stream().map(this::formatRecipient).collect(Collectors.joining(";")));
return row;
}
private String formatRecipient(OpenTypeUserBrokerageRespVO.Recipient recipient) {
String level = recipient.getSourceUserLevel() == null || recipient.getSourceUserLevel() <= 0
? "层级未知" : recipient.getSourceUserLevel() + "级分销";
String nickname = StrUtil.blankToDefault(recipient.getNickname(), recipient.isMemberExists() ? "未设置昵称" : "会员资料缺失");
String userCode = StrUtil.blankToDefault(recipient.getUserCode(), Objects.toString(recipient.getUserId(), "未知"));
return String.format("%s %s(ID %s) 已结算 %s元,待结算 %s元,已取消 %s元", level, nickname, userCode,
BigDecimal.valueOf(recipient.getSettledPrice(), 2).toPlainString(),
BigDecimal.valueOf(recipient.getPendingPrice(), 2).toPlainString(),
BigDecimal.valueOf(recipient.getCancelledPrice(), 2).toPlainString());
}
}
@@ -11,5 +11,7 @@ public class MemberUserExportTask extends MemberUserExportRespVO {
private Long userId;
private Long tenantId;
private String workerId;
/** null 兼容已有会员用户导出任务。 */
private String exportType;
private long updatedAt;
}
@@ -4,10 +4,12 @@ import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserExportRespVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.dal.redis.user.MemberUserExportRedisDAO;
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeUserExportWriter;
import cn.iocoder.yudao.module.member.service.user.BO.MemberUserExportTask;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
@@ -29,6 +31,8 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.stream.Stream;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@@ -37,6 +41,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
@Slf4j
public class MemberUserExportService {
private static final String HISTORY_EXPORT_TYPE = "open-type-history";
private final String workerId = UUID.randomUUID().toString();
private final Set<String> liveWorkers = ConcurrentHashMap.newKeySet();
@@ -45,11 +50,22 @@ public class MemberUserExportService {
@Resource
private MemberUserExportWriter exportWriter;
@Resource
private OpenTypeUserExportWriter historyExportWriter;
@Resource
private MemberUserExportProperties properties;
@Resource(name = "memberUserExportExecutor")
private Executor executor;
public MemberUserExportRespVO create(MemberUserPageReqVO query) {
return create(null, "会员用户", (file, progress) -> exportWriter.write(file, query, progress));
}
public MemberUserExportRespVO createHistory(OpenTypeUserReqVO query) {
return create(HISTORY_EXPORT_TYPE, "会员开通记录", (file, progress) -> historyExportWriter.write(file, query, progress));
}
private MemberUserExportRespVO create(String exportType, String fileNamePrefix,
BiFunction<Path, BiConsumer<Long, Long>, Long> writer) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
Long tenantId = TenantContextHolder.getTenantId();
if (userId == null) {
@@ -59,16 +75,17 @@ public class MemberUserExportService {
task.setTaskId(UUID.randomUUID().toString());
task.setUserId(userId);
task.setTenantId(tenantId);
task.setExportType(exportType);
task.setWorkerId(workerId + ":" + task.getTaskId());
task.setStatus("WAITING");
task.setMessage("导出任务已提交,等待处理");
task.setFileName("会员用户_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".xlsx");
if (!exportRedisDAO.acquire(tenantId, userId, task.getTaskId())) {
String activeId = exportRedisDAO.getActiveTaskId(tenantId, userId);
task.setFileName(fileNamePrefix + "_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".xlsx");
if (!exportRedisDAO.acquire(tenantId, userId, task.getTaskId(), exportType)) {
String activeId = exportRedisDAO.getActiveTaskId(tenantId, userId, exportType);
if (activeId == null) {
throw invalidParamException("导出任务状态正在更新,请重试");
}
MemberUserExportTask active = getOwnedTask(activeId);
MemberUserExportTask active = getOwnedTask(activeId, exportType);
if (isActive(active)) {
MemberUserExportRespVO response = toResponse(active);
response.setExistingTask(true);
@@ -85,7 +102,7 @@ public class MemberUserExportService {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(SecurityFrameworkUtils.getAuthentication());
executor.execute(new DelegatingSecurityContextRunnable(
() -> TenantUtils.execute(tenantId, () -> runExport(task, query)), context));
() -> TenantUtils.execute(tenantId, () -> runExport(task, writer)), context));
} catch (RejectedExecutionException exception) {
liveWorkers.remove(task.getWorkerId());
fail(task, "当前导出任务较多,请稍后重试");
@@ -100,11 +117,23 @@ public class MemberUserExportService {
}
public MemberUserExportRespVO getProgress(String taskId) {
return toResponse(getOwnedTask(taskId));
return toResponse(getOwnedTask(taskId, null));
}
public MemberUserExportRespVO getHistoryProgress(String taskId) {
return toResponse(getOwnedTask(taskId, HISTORY_EXPORT_TYPE));
}
public Path getDownloadFile(String taskId) {
MemberUserExportTask task = getOwnedTask(taskId);
return getDownloadFile(taskId, null);
}
public Path getHistoryDownloadFile(String taskId) {
return getDownloadFile(taskId, HISTORY_EXPORT_TYPE);
}
private Path getDownloadFile(String taskId, String exportType) {
MemberUserExportTask task = getOwnedTask(taskId, exportType);
if (!"SUCCESS".equals(task.getStatus())) {
throw invalidParamException("导出文件尚未生成,请等待任务完成");
}
@@ -115,14 +144,14 @@ public class MemberUserExportService {
return file;
}
private void runExport(MemberUserExportTask task, MemberUserPageReqVO query) {
private void runExport(MemberUserExportTask task, BiFunction<Path, BiConsumer<Long, Long>, Long> writer) {
Path partial = filePath(task.getTaskId(), ".part");
try {
Files.createDirectories(partial.getParent());
task.setStatus("RUNNING");
task.setMessage("正在统计并分批导出");
exportRedisDAO.save(task);
long processed = exportWriter.write(partial, query, (total, count) -> {
long processed = writer.apply(partial, (total, count) -> {
task.setTotal(total);
task.setProcessed(count);
task.setProgress(total == 0 ? 0 : (int) Math.min(99, count * 100 / total));
@@ -147,7 +176,7 @@ public class MemberUserExportService {
}
}
private MemberUserExportTask getOwnedTask(String taskId) {
private MemberUserExportTask getOwnedTask(String taskId, String exportType) {
if (taskId == null || !taskId.matches("[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}")) {
throw invalidParamException("导出任务不存在或已过期,请重新导出");
}
@@ -155,7 +184,7 @@ public class MemberUserExportService {
Long tenantId = TenantContextHolder.getTenantId();
MemberUserExportTask task = exportRedisDAO.get(tenantId, userId, taskId);
if (task == null || userId == null || !Objects.equals(userId, task.getUserId())
|| !Objects.equals(tenantId, task.getTenantId())) {
|| !Objects.equals(tenantId, task.getTenantId()) || !Objects.equals(exportType, task.getExportType())) {
throw invalidParamException("导出任务不存在或已过期,请重新导出");
}
if (isActive(task) && System.currentTimeMillis() - task.getUpdatedAt() > TimeUnit.MINUTES.toMillis(2)
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.member.service.user;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserExportRowVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
@@ -51,7 +52,8 @@ public class MemberUserExportWriter {
Long lastId = null;
int sheetIndex = 0;
int sheetRows = 0;
try (ExcelWriter writer = EasyExcel.write(file.toFile(), MemberUserExportRowVO.class).inMemory(false).build()) {
try (ExcelWriter writer = ExcelUtils.writeBuilder(MemberUserExportRowVO.class)
.file(file.toFile()).inMemory(false).build()) {
WriteSheet sheet = newSheet(sheetIndex);
writer.write(Collections.emptyList(), sheet); // 零结果也生成包含列头的合法文件。
while (upperBound != null) {
@@ -31,15 +31,67 @@
<if test="dto.memberId != null">
and a.member_id = #{dto.memberId}
</if>
<if test="dto.payStatus != null and dto.payStatus != '' ">
and a.pay_status = #{dto.payStatus}
<if test="dto.payStatus != null">
<choose>
<when test="dto.payStatus">
and (a.pay_status = 1 or a.pay_time is not null)
</when>
<otherwise>
and (a.pay_status = 0 or a.pay_status is null) and a.pay_time is null
</otherwise>
</choose>
</if>
<if test="dto.refundStatus != null and dto.refundStatus != '' ">
and a.refund_status = #{dto.refundStatus}
<if test="dto.refundStatus != null and dto.refundStatus != ''">
<choose>
<when test='dto.refundStatus == "0"'>
and (a.refund_status is null or a.refund_status = 0)
</when>
<otherwise>
and a.refund_status = #{dto.refundStatus}
</otherwise>
</choose>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.size() != 0">
and a.createTime between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
<if test="dto.memberName != null and dto.memberName != ''">
and u.nickname like concat('%', #{dto.memberName}, '%')
</if>
<if test="dto.userCode != null and dto.userCode != ''">
and u.user_code = #{dto.userCode}
</if>
<if test="dto.memberMobile != null and dto.memberMobile != ''">
and u.mobile like concat('%', #{dto.memberMobile}, '%')
</if>
<if test="dto.title != null and dto.title != ''">
and o.title like concat('%', #{dto.title}, '%')
</if>
<if test="dto.payTime != null and dto.payTime.length == 2">
and a.pay_time between #{dto.payTime[0]} and #{dto.payTime[1]}
</if>
<if test="dto.refundTime != null and dto.refundTime.length == 2">
and a.refund_time between #{dto.refundTime[0]} and #{dto.refundTime[1]}
</if>
<if test="dto.createTime != null and dto.createTime.length == 2">
and a.create_time between #{dto.createTime[0]} and #{dto.createTime[1]}
</if>
<if test="dto.createTimeList != null and dto.createTimeList.size() == 2">
and a.create_time &gt;= #{dto.createTimeList[0]}
and a.create_time &lt; date_add(#{dto.createTimeList[1]}, interval 1 day)
</if>
</sql>
<sql id="historyJoins">
from member_open_type_user a
left join member_user u on a.member_id = u.id
left join member_open_type o on a.open_type_id = o.id
</sql>
<sql id="historyColumns">
<include refid="selectA"/>,
u.nickname as memberNickname,
u.user_code as memberUserCode,
u.mobile as memberMobile,
u.avatar as memberAvatar,
o.title as vipTitle,
o.duration as vipDuration
</sql>
<select id="getExtByMemeberIdList" resultType="cn.iocoder.yudao.module.member.service.opentype.BO.OpenTypeExtBO">
@@ -64,21 +116,42 @@
<select id="page"
resultType="cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO">
<include refid="selectA"/>,
u.nickname as memberNickname,
u.mobile as memberMobile,
u.avatar as memberAvatar,
o.title as vipTitle,
o.duration as vipDuration
from member_open_type_user a
left join member_user u on a.member_id = u.id
left join member_open_type o on a.open_type_id = o.id
<include refid="historyColumns"/>
<include refid="historyJoins"/>
<where>
<include refid="whereA"/>
<if test="dto.memberName != null">
and u.nickname like concat('%', #{dto.memberName}, '%')
</if>
</where>
order by a.create_time desc , a.id desc
</select>
<select id="selectExportUpperBound" resultType="java.lang.Long">
select max(a.id)
<include refid="historyJoins"/>
<where><include refid="whereA"/></where>
</select>
<select id="selectExportCount" resultType="java.lang.Long">
select count(*)
<include refid="historyJoins"/>
<where>
<include refid="whereA"/>
and a.id &lt;= #{upperId}
</where>
</select>
<select id="selectExportBatch"
resultType="cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO">
<include refid="historyColumns"/>
<include refid="historyJoins"/>
<where>
<include refid="whereA"/>
and a.id &lt;= #{upperId}
<if test="lastId != null">
and (a.create_time &lt; #{lastCreateTime}
or (a.create_time = #{lastCreateTime} and a.id &lt; #{lastId}))
</if>
</where>
order by a.create_time desc, a.id desc
limit #{limit}
</select>
</mapper>
@@ -0,0 +1,72 @@
package cn.iocoder.yudao.module.member.controller.admin.opentype;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.*;
class OpenTypeUserExportControllerTest extends BaseMockitoUnitTest {
@InjectMocks
private OpenTypeUserController controller;
@Mock
private MemberUserExportService service;
@TempDir
Path directory;
@Test
void bindsAllFiltersAndFalsePaymentStatusWithIndexedDateRanges() throws Exception {
MockMvcBuilders.standaloneSetup(controller).build()
.perform(post("/member/open-type/history/export-async")
.param("memberName", "测试会员").param("memberMobile", "139")
.param("userCode", "000001").param("title", "月卡").param("payStatus", "false")
.param("refundStatus", "0")
.param("payTime[0]", "2026-11-01 00:00:00").param("payTime[1]", "2026-11-11 23:59:59")
.param("refundTime[0]", "2026-11-05 00:00:00").param("refundTime[1]", "2026-11-11 23:59:59")
.param("createTime[0]", "2026-10-01 00:00:00").param("createTime[1]", "2026-11-11 23:59:59"))
.andExpect(status().isOk());
ArgumentCaptor<OpenTypeUserReqVO> captor = ArgumentCaptor.forClass(OpenTypeUserReqVO.class);
verify(service).createHistory(captor.capture());
OpenTypeUserReqVO query = captor.getValue();
assertThat(query.getMemberName()).isEqualTo("测试会员");
assertThat(query.getMemberMobile()).isEqualTo("139");
assertThat(query.getUserCode()).isEqualTo("000001");
assertThat(query.getTitle()).isEqualTo("月卡");
assertThat(query.getPayStatus()).isFalse();
assertThat(query.getRefundStatus()).isEqualTo("0");
assertThat(query.getPayTime()).containsExactly(LocalDateTime.of(2026, 11, 1, 0, 0),
LocalDateTime.of(2026, 11, 11, 23, 59, 59));
assertThat(query.getRefundTime()).containsExactly(LocalDateTime.of(2026, 11, 5, 0, 0),
LocalDateTime.of(2026, 11, 11, 23, 59, 59));
assertThat(query.getCreateTime()).containsExactly(LocalDateTime.of(2026, 10, 1, 0, 0),
LocalDateTime.of(2026, 11, 11, 23, 59, 59));
}
@Test
void downloadStreamsTheHistoryFile() throws Exception {
Path file = Files.write(directory.resolve("history.xlsx"), new byte[]{1, 2, 3});
when(service.getHistoryDownloadFile("history-task")).thenReturn(file);
MockMvcBuilders.standaloneSetup(controller).build()
.perform(get("/member/open-type/history/export-download").param("taskId", "history-task"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(content().bytes(new byte[]{1, 2, 3}));
}
}
@@ -14,6 +14,8 @@ import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService;
import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageUserApi;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageIncomeSummaryRespDTO;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -34,6 +36,8 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -54,6 +58,7 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
@Mock private MemberRecentCfgService memberRecentCfgService;
@Mock private AddressService addressService;
@Mock private TradeBrokerageUserApi tradeBrokerageUserApi;
@Mock private TradeBrokerageRecordApi tradeBrokerageRecordApi;
@Test
void pageLoadsRelationshipsAndInvitersInBatchesAndPreservesMissingMembers() {
@@ -65,6 +70,8 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
when(tradeBrokerageUserApi.getBrokerageUserList(anyCollection())).thenReturn(Arrays.asList(
relation(1L, 10L), relation(2L, 10L), relation(3L, 99L)));
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(user(10L, "邀请人")));
when(tradeBrokerageRecordApi.getIncomeSummaryMap(anyCollection())).thenReturn(
Collections.singletonMap(1L, summary(12345L, 678L)));
List<MemberUserRespVO> result = controller.getUserPage(new MemberUserPageReqVO()).getData().getList();
@@ -77,6 +84,11 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
assertThat(result.get(2).getInviter().getId()).isEqualTo(99L);
assertThat(result.get(2).getInviter().getMemberExists()).isFalse();
assertThat(result.get(3).getInviter()).isNull();
assertThat(result.get(0).getBrokerageSummary().getSettledPrice()).isEqualTo(12345L);
assertThat(result.get(0).getBrokerageSummary().getPendingPrice()).isEqualTo(678L);
assertThat(result.get(3).getBrokerageSummary().getSettledPrice()).isZero();
verify(tradeBrokerageRecordApi).getIncomeSummaryMap(new HashSet<>(Arrays.asList(1L, 2L, 3L, 4L)));
verify(tradeBrokerageRecordApi, never()).getContributionSummaryMap(anyLong(), anyCollection());
verify(tradeBrokerageUserApi).getBrokerageUserList(new HashSet<>(Arrays.asList(1L, 2L, 3L, 4L)));
verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(10L, 99L)));
}
@@ -96,9 +108,32 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
@Test
void emptyMemberPageDoesNotQueryRelationships() {
when(memberUserService.getUserPage(any())).thenReturn(PageResult.empty());
assertThat(controller.getUserPage(new MemberUserPageReqVO()).getData().getList()).isEmpty();
verifyNoInteractions(tradeBrokerageUserApi);
when(memberUserService.getUserPage(any())).thenReturn(new PageResult<>(Collections.emptyList(), 25L));
PageResult<MemberUserRespVO> result = controller.getUserPage(new MemberUserPageReqVO()).getData();
assertThat(result.getList()).isEmpty();
assertThat(result.getTotal()).isEqualTo(25L);
verifyNoInteractions(tradeBrokerageUserApi, tradeBrokerageRecordApi);
}
@Test
void detailLoadsRootAndInviterIncomeTogetherAndContributionsToRoot() {
when(memberUserService.getUser(1L)).thenReturn(user(1L, "会员"));
when(tradeBrokerageUserApi.getBrokerageUserList(anyCollection())).thenReturn(Collections.singletonList(relation(1L, 10L)));
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(user(10L, "邀请人")));
Map<Long, BrokerageIncomeSummaryRespDTO> income = new HashMap<>();
income.put(1L, summary(300L, 20L));
income.put(10L, summary(400L, 30L));
when(tradeBrokerageRecordApi.getIncomeSummaryMap(anyCollection())).thenReturn(income);
when(tradeBrokerageRecordApi.getContributionSummaryMap(eq(1L), anyCollection())).thenReturn(
Collections.singletonMap(10L, summary(100L, 10L)));
MemberUserRespVO result = controller.getUser(1L).getData();
assertThat(result.getBrokerageSummary().getSettledPrice()).isEqualTo(300L);
assertThat(result.getInviter().getBrokerageSummary().getSettledPrice()).isEqualTo(400L);
assertThat(result.getInviter().getContributionSummary().getSettledPrice()).isEqualTo(100L);
verify(tradeBrokerageRecordApi).getIncomeSummaryMap(new HashSet<>(Arrays.asList(1L, 10L)));
verify(tradeBrokerageRecordApi).getContributionSummaryMap(1L, Collections.singleton(10L));
}
@Test
@@ -114,6 +149,11 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(child));
MemberUserBrokerageChildrenPageReqVO query = new MemberUserBrokerageChildrenPageReqVO();
query.setBindUserId(1L);
query.setContributionUserId(100L);
when(tradeBrokerageRecordApi.getIncomeSummaryMap(anyCollection())).thenReturn(
Collections.singletonMap(20L, summary(300L, 20L)));
when(tradeBrokerageRecordApi.getContributionSummaryMap(eq(100L), anyCollection())).thenReturn(
Collections.singletonMap(20L, summary(100L, 10L)));
PageResult<MemberUserBrokerageChildRespVO> result = controller.getBrokerageChildrenPage(query).getData();
assertThat(result.getTotal()).isEqualTo(25L);
@@ -122,7 +162,22 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
assertThat(result.getList().get(0).getMemberExists()).isTrue();
assertThat(result.getList().get(1).getId()).isEqualTo(19L);
assertThat(result.getList().get(1).getMemberExists()).isFalse();
assertThat(result.getList().get(0).getBrokerageSummary().getSettledPrice()).isEqualTo(300L);
assertThat(result.getList().get(0).getContributionSummary().getPendingPrice()).isEqualTo(10L);
assertThat(result.getList().get(1).getBrokerageSummary().getSettledPrice()).isZero();
assertThat(result.getList().get(1).getContributionSummary().getPendingPrice()).isZero();
verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(20L, 19L)));
verify(tradeBrokerageRecordApi).getContributionSummaryMap(100L, new HashSet<>(Arrays.asList(20L, 19L)));
}
@Test
void childContributionDefaultsToImmediateParentForExistingCallers() {
when(tradeBrokerageUserApi.getBrokerageUserChildrenPage(eq(1L), any())).thenReturn(
new PageResult<>(Collections.singletonList(relation(20L, 1L)), 1L));
MemberUserBrokerageChildrenPageReqVO query = new MemberUserBrokerageChildrenPageReqVO();
query.setBindUserId(1L);
controller.getBrokerageChildrenPage(query);
verify(tradeBrokerageRecordApi).getContributionSummaryMap(1L, Collections.singleton(20L));
}
@Test
@@ -141,6 +196,26 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "0")).andExpect(status().isBadRequest());
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "1").param("pageNo", "0"))
.andExpect(status().isBadRequest());
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "1").param("contributionUserId", "0"))
.andExpect(status().isBadRequest());
}
@Test
void httpValidatesIncomeRangeAndAllowsZeroAndSingleBound() throws Exception {
when(memberUserService.getUserPage(any())).thenReturn(PageResult.empty());
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/member/user/page").param("brokeragePriceMin", "-1"))
.andExpect(status().isBadRequest());
mvc.perform(get("/member/user/page").param("brokeragePriceMax", "-1"))
.andExpect(status().isBadRequest());
mvc.perform(get("/member/user/page").param("brokeragePriceMin", "200").param("brokeragePriceMax", "100"))
.andExpect(status().isBadRequest());
mvc.perform(get("/member/user/page").param("brokeragePriceMin", "0").param("brokeragePriceMax", "0"))
.andExpect(status().isOk());
mvc.perform(get("/member/user/page").param("brokeragePriceMin", "3000000000"))
.andExpect(status().isOk());
verify(memberUserService).getUserPage(argThat(query -> Long.valueOf(3000000000L).equals(query.getBrokeragePriceMin())
&& query.getBrokeragePriceMax() == null));
}
@Test
@@ -191,6 +266,13 @@ class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
return result;
}
private static BrokerageIncomeSummaryRespDTO summary(Long settled, Long pending) {
BrokerageIncomeSummaryRespDTO result = new BrokerageIncomeSummaryRespDTO();
result.setSettledPrice(settled);
result.setPendingPrice(pending);
return result;
}
private static BrokerageUserRespDTO relation(Long id, Long parent) {
BrokerageUserRespDTO result = new BrokerageUserRespDTO();
result.setId(id);
@@ -0,0 +1,107 @@
package cn.iocoder.yudao.module.member.dal.mysql.opentype;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class OpenTypeUserMapperTest {
private MybatisConfiguration configuration;
@BeforeEach
void loadMapper() throws Exception {
configuration = new MybatisConfiguration();
try (InputStream source = getClass().getResourceAsStream("/mapper/OpenTypeUserMapper.xml")) {
new XMLMapperBuilder(source, configuration, "mapper/OpenTypeUserMapper.xml", configuration.getSqlFragments()).parse();
}
}
@Test
void listAndEveryExportQueryApplyTheSameFiltersIncludingFalsePaymentStatus() throws Exception {
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
query.setMemberName("会员");
query.setMemberMobile("139");
query.setUserCode("000001");
query.setTitle("月卡");
query.setPayStatus(false);
query.setPayTime(new LocalDateTime[]{LocalDateTime.of(2026, 11, 1, 0, 0), LocalDateTime.of(2026, 11, 11, 23, 59, 59)});
query.setCreateTime(query.getPayTime());
query.setRefundTime(query.getPayTime());
for (String statement : Arrays.asList("page", "selectExportUpperBound", "selectExportCount", "selectExportBatch")) {
BoundSql bound = sql(statement, query, null);
String sql = bound.getSql().replaceAll("\\s+", " ");
assertThat(sql).contains("u.nickname like", "u.user_code =", "u.mobile like", "o.title like",
"a.pay_time between", "a.refund_time between", "a.create_time between", "a.pay_status = 0", "a.pay_time is null");
assertThat(bound.getParameterMappings()).extracting(mapping -> mapping.getProperty())
.contains("dto.memberName", "dto.memberMobile", "dto.userCode", "dto.title",
"dto.payTime[0]", "dto.payTime[1]", "dto.refundTime[0]", "dto.refundTime[1]",
"dto.createTime[0]", "dto.createTime[1]");
CCJSqlParserUtil.parse(sql);
}
}
@Test
void paidFilterMatchesTheLegacyDisplayRuleAndEmptyFiltersAreOmitted() {
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
query.setPayStatus(true);
assertThat(sql("page", query, null).getSql()).contains("a.pay_status = 1 or a.pay_time is not null");
query.setPayStatus(null);
query.setMemberName("");
assertThat(sql("page", query, null).getSql()).doesNotContain("u.nickname like", "a.pay_status = 1", "a.pay_status = 0",
"a.refund_time between");
}
@Test
void supportsLegacyDateRangeAndUsesCreationTimeAndIdCursorForExportOrder() {
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
query.setCreateTimeList(Arrays.asList(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 11)));
String sql = sql("selectExportBatch", query, 50L).getSql().replaceAll("\\s+", " ");
assertThat(sql).contains("a.create_time >=", "date_add", "a.create_time <", "a.create_time =", "a.id <",
"order by a.create_time desc, a.id desc", "limit ?");
assertThat(sql).doesNotContain("a.createTime");
}
@Test
void refundFiltersAreSharedByListAndExportAndUnrefundedIncludesLegacyNullStatus() throws Exception {
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
for (String statement : Arrays.asList("page", "selectExportUpperBound", "selectExportCount", "selectExportBatch")) {
query.setRefundStatus("0");
String sql = sql(statement, query, null).getSql().replaceAll("\\s+", " ");
assertThat(sql).contains("(a.refund_status is null or a.refund_status = 0)");
CCJSqlParserUtil.parse(sql);
for (String refundStatus : Arrays.asList("10", "20")) {
query.setRefundStatus(refundStatus);
BoundSql bound = sql(statement, query, null);
assertThat(bound.getSql()).contains("a.refund_status = ?").doesNotContain("a.refund_status is null");
assertThat(bound.getParameterMappings()).extracting(mapping -> mapping.getProperty()).contains("dto.refundStatus");
}
query.setRefundStatus(null);
assertThat(sql(statement, query, null).getSql()).doesNotContain("a.refund_status =");
query.setRefundStatus("");
assertThat(sql(statement, query, null).getSql()).doesNotContain("a.refund_status =");
}
}
private BoundSql sql(String statement, OpenTypeUserReqVO query, Long lastId) {
Map<String, Object> params = new HashMap<>();
params.put("dto", query);
params.put("upperId", 100L);
params.put("lastCreateTime", LocalDateTime.of(2026, 11, 10, 12, 0));
params.put("lastId", lastId);
params.put("limit", 500);
return configuration.getMappedStatement(OpenTypeUserMapper.class.getName() + "." + statement).getBoundSql(params);
}
}
@@ -2,6 +2,13 @@ package cn.iocoder.yudao.module.member.dal.mysql.user;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserRespVO;
import cn.iocoder.yudao.module.member.convert.user.MemberUserConvert;
@@ -41,6 +48,8 @@ class MemberUserMapperTest extends BaseDbUnitTest {
private AddressService addressService;
@Resource
private DataSource dataSource;
@Resource
private MybatisPlusInterceptor mybatisPlusInterceptor;
private JdbcTemplate jdbcTemplate;
@@ -82,11 +91,27 @@ class MemberUserMapperTest extends BaseDbUnitTest {
insertAddress(7L, 999999L, false, false); // 无法识别的历史地区不能显示错误名称。
insertAddress(7L, 4295297402L, true, false); // Long 截断后恰好为 330106,不能误显示西湖区。
insertAddress(8L, 330106L, true, false);
insertIncome(1L, 1, 100, 1, "推广佣金", "收入", false);
insertIncome(1L, 2, 200, 1, "会员佣金", "收入", false);
insertIncome(1L, 1, 999, 0, "推广佣金", "待结算", false);
insertIncome(1L, 1, 999, 1, "推广佣金", "已删除", true);
insertIncome(2L, 2, 1000, 1, "会员佣金", "收入", false);
insertIncome(2L, 2, 2000, 1, "提现申请", "历史正数提现", false);
insertIncome(2L, 1, 999, 2, "推广佣金", "已取消", false);
insertIncome(3L, 1, 100, 1, null, null, false);
insertIncome(3L, 2, 200, 1, "会员佣金", "收入", false);
insertIncome(5L, 4, 999, 1, "提现驳回", "退款", false);
insertIncome(5L, 2, 999, 1, "历史流水", "提现申请", false);
insertIncome(6L, 1, 999, 0, "推广佣金", "待结算", false);
insertIncome(7L, 1, 1500000000, 1, "推广佣金", "收入", false);
insertIncome(7L, 2, 1500000000, 1, "会员佣金", "收入", false);
insertIncome(8L, 1, 300, 1, "推广佣金", "已删除会员", false);
}
@AfterEach
void cleanRecentTags() {
jdbcTemplate.update("DELETE FROM member_recent_tag");
jdbcTemplate.update("DELETE FROM trade_brokerage_record");
}
@Test
@@ -95,6 +120,61 @@ class MemberUserMapperTest extends BaseDbUnitTest {
7L, 6L, 5L, 4L, 3L, 2L, 1L);
}
@Test
void incomeRangeUsesSettledIncomeAndSupportsInclusiveSingleBounds() {
MemberUserPageReqVO request = new MemberUserPageReqVO();
request.setBrokeragePriceMin(300L);
assertPage(memberUserMapper.selectPage(request), 4L, 7L, 3L, 2L, 1L);
request.setBrokeragePriceMax(1000L);
assertPage(memberUserMapper.selectPage(request), 3L, 3L, 2L, 1L);
request.setBrokeragePriceMin(null);
request.setBrokeragePriceMax(0L);
assertPage(memberUserMapper.selectPage(request), 3L, 6L, 5L, 4L);
request.setBrokeragePriceMin(0L);
assertPage(memberUserMapper.selectPage(request), 3L, 6L, 5L, 4L);
request.setBrokeragePriceMin(3000000000L);
request.setBrokeragePriceMax(3000000000L);
assertPage(memberUserMapper.selectPage(request), 1L, 7L);
}
@Test
void incomeRangeFiltersBeforePaginationAndCombinesWithExistingConditions() {
MemberUserPageReqVO request = new MemberUserPageReqVO();
request.setBrokeragePriceMin(300L);
request.setBrokeragePriceMax(300L);
request.setPageSize(1);
assertPage(memberUserMapper.selectPage(request), 2L, 3L);
request.setPageNo(2);
assertPage(memberUserMapper.selectPage(request), 2L, 1L);
request.setPageNo(1);
request.setWechatNum("bob");
request.setSex(1);
request.setStatus(0);
request.setMemberRecentTagList(Collections.singletonList("5"));
assertPage(memberUserMapper.selectPage(request), 1L, 3L);
}
@Test
void tenantInterceptorIsolatesBothMemberAndIncomeSubquery() {
jdbcTemplate.update("UPDATE member_user SET tenant_id = 1 WHERE id = 2");
insertIncome(1L, 1, 100000, 1, "推广佣金", "其他租户", false);
insertIncome(4L, 1, 400, 1, "推广佣金", "其他租户", false);
jdbcTemplate.update("UPDATE trade_brokerage_record SET tenant_id = 1 WHERE description = '其他租户'");
List<InnerInterceptor> existing = new ArrayList<>(mybatisPlusInterceptor.getInterceptors());
MyBatisUtils.addInterceptor(mybatisPlusInterceptor,
new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(new TenantProperties())), 0);
TenantContextHolder.setTenantId(0L);
try {
MemberUserPageReqVO request = new MemberUserPageReqVO();
request.setBrokeragePriceMin(300L);
request.setBrokeragePriceMax(1000L);
assertPage(memberUserMapper.selectPage(request), 2L, 3L, 1L);
} finally {
mybatisPlusInterceptor.setInterceptors(existing);
TenantContextHolder.clear();
}
}
@Test
void selectPageBySex() {
MemberUserPageReqVO request = new MemberUserPageReqVO();
@@ -360,6 +440,13 @@ class MemberUserMapperTest extends BaseDbUnitTest {
memberId, type, deleted);
}
private void insertIncome(Long userId, int bizType, int price, int status,
String title, String description, boolean deleted) {
jdbcTemplate.update("INSERT INTO trade_brokerage_record"
+ " (user_id, biz_type, price, status, title, description, deleted) VALUES (?, ?, ?, ?, ?, ?, ?)",
userId, bizType, price, status, title, description, deleted);
}
@Test
void exportCountAndCursorUseAllListFiltersAndIgnorePageLimits() {
jdbcTemplate.update("UPDATE member_user SET create_time = '2026-10-01 10:00:00', login_date = '2026-10-01 10:00:00'");
@@ -403,6 +490,14 @@ class MemberUserMapperTest extends BaseDbUnitTest {
query.setLoginDate(new LocalDateTime[]{LocalDateTime.of(2026, 10, 21, 0, 0), LocalDateTime.of(2026, 10, 21, 23, 59)});
queries.add(query);
query = new MemberUserPageReqVO();
query.setBrokeragePriceMin(300L);
query.setBrokeragePriceMax(1000L);
queries.add(query);
query = new MemberUserPageReqVO();
query.setBrokeragePriceMin(0L);
query.setBrokeragePriceMax(0L);
queries.add(query);
query = new MemberUserPageReqVO();
query.setSex(2);
query.setStatus(1);
query.setWechatNum("alice");
@@ -0,0 +1,105 @@
package cn.iocoder.yudao.module.member.service.opentype;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeUserMapper;
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageOrderSummaryRespDTO;
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Arrays;
import java.util.Collections;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OpenTypeUserBrokerageTest extends BaseMockitoUnitTest {
@InjectMocks
private OpenTypeServiceImpl service;
@Mock
private OpenTypeUserMapper mapper;
@Mock
private TradeBrokerageRecordApi brokerageApi;
@Mock
private MemberUserService memberUserService;
@Test
void pageUsesStoredOrderRecipientsAndBatchesProfilesIncludingMissingMembers() {
OpenTypeUserRespVO paid = record(100L);
OpenTypeUserRespVO empty = record(101L);
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
Page<OpenTypeUserRespVO> page = new Page<>();
page.setRecords(Arrays.asList(paid, empty));
page.setTotal(2);
when(mapper.page(any(), eq(query))).thenReturn(page);
BrokerageOrderSummaryRespDTO summary = new BrokerageOrderSummaryRespDTO();
summary.setSettledPrice(250L);
summary.setPendingPrice(100L);
summary.setCancelledPrice(300L);
BrokerageOrderSummaryRespDTO.Recipient first = new BrokerageOrderSummaryRespDTO.Recipient();
first.setUserId(10L);
first.setSourceUserLevel(1);
first.setSettledPrice(250L);
BrokerageOrderSummaryRespDTO.Recipient missing = new BrokerageOrderSummaryRespDTO.Recipient();
missing.setUserId(30L);
missing.setSourceUserLevel(2);
missing.setCancelledPrice(300L);
summary.setRecipients(Arrays.asList(first, missing));
when(brokerageApi.getOrderSummaryMap(BrokerageRecordBizTypeEnum.OPEN_TYPE, Arrays.asList("100", "101")))
.thenReturn(Collections.singletonMap("100", summary));
when(memberUserService.getUserList(any())).thenReturn(Collections.singletonList(new MemberUserDO()
.setId(10L).setUserCode("000010").setNickname("实际收益人").setAvatar("avatar.png")));
assertThat(service.getOpenTypeUserPage(query).getTotal()).isEqualTo(2);
assertThat(paid.getBrokerageSummary().getSettledPrice()).isEqualTo(250);
assertThat(paid.getBrokerageSummary().getPendingPrice()).isEqualTo(100);
assertThat(paid.getBrokerageSummary().getCancelledPrice()).isEqualTo(300);
assertThat(paid.getBrokerageSummary().getRecipients()).hasSize(2);
assertThat(paid.getBrokerageSummary().getRecipients().get(0).getUserCode()).isEqualTo("000010");
assertThat(paid.getBrokerageSummary().getRecipients().get(0).getNickname()).isEqualTo("实际收益人");
assertThat(paid.getBrokerageSummary().getRecipients().get(0).isMemberExists()).isTrue();
assertThat(paid.getBrokerageSummary().getRecipients().get(1).getUserId()).isEqualTo(30);
assertThat(paid.getBrokerageSummary().getRecipients().get(1).isMemberExists()).isFalse();
assertThat(paid.getBrokerageSummary().getRecipients().get(1).getCancelledPrice()).isEqualTo(300);
assertThat(empty.getBrokerageSummary().getSettledPrice()).isZero();
assertThat(empty.getBrokerageSummary().getRecipients()).isEmpty();
verify(memberUserService).getUserList(argThat(ids -> ids.size() == 2 && ids.containsAll(Arrays.asList(10L, 30L))));
verifyNoMoreInteractions(memberUserService);
}
@Test
void emptyOrdersAndNoCommissionDoNotQueryMemberProfiles() {
service.fillBrokerageSummary(Collections.emptyList());
verifyNoInteractions(brokerageApi, memberUserService);
when(brokerageApi.getOrderSummaryMap(any(), any())).thenReturn(Collections.emptyMap());
OpenTypeUserRespVO record = record(100L);
service.fillBrokerageSummary(Collections.singletonList(record));
assertThat(record.getBrokerageSummary().getSettledPrice()).isZero();
assertThat(record.getBrokerageSummary().getPendingPrice()).isZero();
assertThat(record.getBrokerageSummary().getCancelledPrice()).isZero();
verifyNoInteractions(memberUserService);
}
@Test
void commissionQueryFailuresPropagate() {
when(brokerageApi.getOrderSummaryMap(any(), any())).thenThrow(new IllegalStateException("查询失败"));
assertThatThrownBy(() -> service.fillBrokerageSummary(Collections.singletonList(record(100L))))
.isInstanceOf(IllegalStateException.class);
}
private OpenTypeUserRespVO record(Long id) {
OpenTypeUserRespVO record = new OpenTypeUserRespVO();
record.setId(id);
record.setMemberId(99L);
return record;
}
}
@@ -0,0 +1,149 @@
package cn.iocoder.yudao.module.member.service.opentype;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserBrokerageRespVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserRespVO;
import cn.iocoder.yudao.module.member.dal.mysql.opentype.OpenTypeUserMapper;
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
import org.apache.poi.ss.usermodel.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OpenTypeUserExportWriterTest extends BaseMockitoUnitTest {
@InjectMocks
private OpenTypeUserExportWriter writer;
@Mock
private OpenTypeUserMapper mapper;
@Mock
private OpenTypeService openTypeService;
@TempDir
Path directory;
@Test
void exportsBatchesAcrossSheetsWithCurrencyTextIdsAndLegacyPaymentStatus() throws Exception {
MemberUserExportProperties properties = new MemberUserExportProperties();
properties.setBatchSize(2);
properties.setRowsPerSheet(1);
ReflectionTestUtils.setField(writer, "properties", properties);
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
OpenTypeUserRespVO first = record(3L);
first.setPayStatus(false);
first.setPayTime(LocalDateTime.of(2026, 11, 11, 10, 0));
first.setPayOrderId(9007199254740993L);
first.setRefundStatus(10);
first.setRefundPayPrice(1234);
OpenTypeUserRespVO second = record(2L);
second.setPayStatus(false);
when(mapper.selectExportUpperBound(query)).thenReturn(3L);
when(mapper.selectExportCount(query, 3L)).thenReturn(2L);
when(mapper.selectExportBatch(query, 3L, null, null, 2)).thenReturn(Arrays.asList(first, second));
when(mapper.selectExportBatch(query, 3L, second.getCreateTime(), 2L, 2)).thenReturn(Collections.emptyList());
doAnswer(invocation -> {
OpenTypeUserBrokerageRespVO summary = new OpenTypeUserBrokerageRespVO();
summary.setSettledPrice(250L);
summary.setPendingPrice(100L);
summary.setCancelledPrice(300L);
OpenTypeUserBrokerageRespVO.Recipient recipient = new OpenTypeUserBrokerageRespVO.Recipient();
recipient.setUserId(10L);
recipient.setUserCode("000010");
recipient.setNickname("分销人");
recipient.setMemberExists(true);
recipient.setSourceUserLevel(1);
recipient.setSettledPrice(250L);
recipient.setPendingPrice(100L);
recipient.setCancelledPrice(300L);
summary.setRecipients(Collections.singletonList(recipient));
first.setBrokerageSummary(summary);
return null;
}).when(openTypeService).fillBrokerageSummary(anyList());
List<Long> progress = new ArrayList<>();
Path file = directory.resolve("history.xlsx");
assertThat(writer.write(file, query, (total, count) -> progress.add(count))).isEqualTo(2);
assertThat(progress).containsExactly(0L, 2L);
try (Workbook workbook = WorkbookFactory.create(file.toFile())) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(2);
Row row = workbook.getSheetAt(0).getRow(1);
assertThat(row.getCell(1).getStringCellValue()).isEqualTo("000001");
assertThat(row.getCell(2).getStringCellValue()).isEqualTo("=1+1");
assertThat(row.getCell(2).getCellType()).isEqualTo(CellType.STRING);
assertThat(row.getCell(3).getStringCellValue()).isEqualTo("0013900000000");
assertThat(row.getCell(6).getNumericCellValue()).isEqualTo(12.34);
assertThat(row.getCell(7).getStringCellValue()).isEqualTo("支付成功");
assertThat(row.getCell(9).getStringCellValue()).isEqualTo("9007199254740993");
assertThat(row.getCell(11).getStringCellValue()).isEqualTo("退款成功");
assertThat(row.getCell(12).getNumericCellValue()).isEqualTo(12.34);
assertThat(new DataFormatter().formatCellValue(row.getCell(14))).isEqualTo("2026-11-11 09:00:00");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(7).getStringCellValue()).isEqualTo("未支付");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(11).getStringCellValue()).isEqualTo("未退款");
assertThat(row.getCell(15).getNumericCellValue()).isEqualTo(2.50);
assertThat(row.getCell(16).getNumericCellValue()).isEqualTo(1.00);
assertThat(row.getCell(17).getNumericCellValue()).isEqualTo(3.00);
assertThat(new DataFormatter().formatCellValue(row.getCell(15))).isEqualTo("2.50");
assertThat(new DataFormatter().formatCellValue(row.getCell(16))).isEqualTo("1.00");
assertThat(new DataFormatter().formatCellValue(row.getCell(17))).isEqualTo("3.00");
assertThat(row.getCell(18).getStringCellValue()).contains("1级分销", "分销人", "ID 000010",
"已结算 2.50元", "待结算 1.00元", "已取消 3.00元");
for (Sheet sheet : workbook) {
assertThat(sheet.getColumnWidth(18)).isEqualTo(200 * 256);
for (Row exportedRow : sheet) {
assertThat(exportedRow.getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
}
for (Cell cell : sheet.getRow(0)) {
assertThat(cell.getCellStyle().getAlignment()).isEqualTo(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
assertThat(cell.getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
}
}
assertThat(row.getCell(0).getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
}
verify(openTypeService).fillBrokerageSummary(Arrays.asList(first, second));
}
@Test
void emptySearchStillProducesColumnHeaders() throws Exception {
ReflectionTestUtils.setField(writer, "properties", new MemberUserExportProperties());
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
when(mapper.selectExportUpperBound(query)).thenReturn(null);
Path file = directory.resolve("empty.xlsx");
assertThat(writer.write(file, query, (total, count) -> assertThat(count).isZero())).isZero();
try (Workbook workbook = WorkbookFactory.create(file.toFile())) {
assertThat(workbook.getSheetAt(0).getLastRowNum()).isZero();
assertThat(workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue()).isEqualTo("记录编号");
assertThat(workbook.getSheetAt(0).getColumnWidth(18)).isEqualTo(200 * 256);
assertThat(workbook.getSheetAt(0).getRow(0).getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
assertThat(workbook.getSheetAt(0).getRow(0).getCell(0).getCellStyle().getAlignment())
.isEqualTo(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
}
verify(mapper, never()).selectExportBatch(any(), any(), any(), any(), anyInt());
verifyNoInteractions(openTypeService);
}
private OpenTypeUserRespVO record(Long id) {
OpenTypeUserRespVO record = new OpenTypeUserRespVO();
record.setId(id);
record.setMemberUserCode("000001");
record.setMemberNickname("=1+1");
record.setMemberMobile("0013900000000");
record.setVipTitle("月卡");
record.setVipDuration("30");
record.setPayPrice(1234);
record.setCreateTime(LocalDateTime.of(2026, 11, 11, 9, 0));
return record;
}
}
@@ -5,11 +5,13 @@ import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.member.controller.admin.opentype.vo.openTypeUser.OpenTypeUserReqVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserExportRespVO;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.dal.redis.user.MemberUserExportRedisDAO;
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
import cn.iocoder.yudao.module.member.service.user.BO.MemberUserExportTask;
import cn.iocoder.yudao.module.member.service.opentype.OpenTypeUserExportWriter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,6 +42,8 @@ class MemberUserExportServiceTest extends BaseMockitoUnitTest {
private MemberUserExportRedisDAO exportRedisDAO;
@Mock
private MemberUserExportWriter exportWriter;
@Mock
private OpenTypeUserExportWriter historyExportWriter;
@TempDir
Path directory;
@@ -54,7 +58,7 @@ class MemberUserExportServiceTest extends BaseMockitoUnitTest {
properties.setDirectory(directory.toString());
ReflectionTestUtils.setField(service, "properties", properties);
ReflectionTestUtils.setField(service, "executor", (Executor) queue::add);
lenient().when(exportRedisDAO.acquire(any(), any(), anyString())).thenReturn(true);
lenient().when(exportRedisDAO.acquire(any(), any(), anyString(), nullable(String.class))).thenReturn(true);
lenient().doAnswer(invocation -> {
MemberUserExportTask task = invocation.getArgument(0);
task.setUpdatedAt(System.currentTimeMillis());
@@ -147,8 +151,8 @@ class MemberUserExportServiceTest extends BaseMockitoUnitTest {
@Test
void duplicateSubmissionsReturnExistingTaskWithoutQueuingAnotherExport() {
String taskId = service.create(new MemberUserPageReqVO()).getTaskId();
when(exportRedisDAO.acquire(any(), any(), anyString())).thenReturn(false);
when(exportRedisDAO.getActiveTaskId(1L, 100L)).thenReturn(taskId);
when(exportRedisDAO.acquire(any(), any(), anyString(), isNull())).thenReturn(false);
when(exportRedisDAO.getActiveTaskId(1L, 100L, null)).thenReturn(taskId);
MemberUserExportRespVO duplicate = service.create(new MemberUserPageReqVO());
assertThat(duplicate.getTaskId()).isEqualTo(taskId);
@@ -197,6 +201,33 @@ class MemberUserExportServiceTest extends BaseMockitoUnitTest {
assertThat(expiredImages).doesNotExist();
}
@Test
void historyUsesItsOwnWriterAndTaskTypeAndCannotBeDownloadedThroughUserEndpoints() throws Exception {
OpenTypeUserReqVO query = new OpenTypeUserReqVO();
query.setPayStatus(false);
when(historyExportWriter.write(any(), eq(query), any())).thenAnswer(invocation -> {
Files.write(invocation.getArgument(0), new byte[]{4, 5, 6});
return 2L;
});
String userTaskId = service.create(new MemberUserPageReqVO()).getTaskId();
MemberUserExportRespVO submitted = service.createHistory(query);
assertThat(submitted.getFileName()).startsWith("会员开通记录_");
assertThat(queue).hasSize(2);
assertThatThrownBy(() -> service.getProgress(submitted.getTaskId())).isInstanceOf(ServiceException.class);
assertThatThrownBy(() -> service.getHistoryProgress(userTaskId)).isInstanceOf(ServiceException.class);
assertThatThrownBy(() -> service.getHistoryDownloadFile(userTaskId)).isInstanceOf(ServiceException.class);
queue.remove(1).run();
assertThat(service.getHistoryProgress(submitted.getTaskId()).getStatus()).isEqualTo("SUCCESS");
assertThat(Files.readAllBytes(service.getHistoryDownloadFile(submitted.getTaskId()))).containsExactly(4, 5, 6);
assertThatThrownBy(() -> service.getDownloadFile(submitted.getTaskId())).isInstanceOf(ServiceException.class);
verifyNoInteractions(exportWriter);
verify(exportRedisDAO).acquire(1L, 100L, submitted.getTaskId(), "open-type-history");
login(200L, 1L);
assertThatThrownBy(() -> service.getHistoryProgress(submitted.getTaskId())).isInstanceOf(ServiceException.class);
login(100L, 2L);
assertThatThrownBy(() -> service.getHistoryDownloadFile(submitted.getTaskId())).isInstanceOf(ServiceException.class);
}
private void login(Long userId, Long tenantId) {
LoginUser user = new LoginUser();
user.setId(userId);
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.member.service.user;
import cn.iocoder.yudao.framework.excel.core.style.ExcelExportStyleTokens;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
@@ -112,7 +113,16 @@ class MemberUserExportWriterTest extends BaseMockitoUnitTest {
assertThat(new DataFormatter().formatCellValue(row.getCell(8))).isEqualTo("2026-10-30 10:20:30");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(0).getStringCellValue()).isEqualTo("u2");
assertThat(workbook.getSheetAt(1).getRow(2).getCell(0).getStringCellValue()).isEqualTo("u1");
assertThat(row.getHeightInPoints()).isEqualTo(24);
for (Sheet sheet : workbook) {
for (Row exportedRow : sheet) {
assertThat(exportedRow.getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
}
for (Cell cell : sheet.getRow(0)) {
assertThat(cell.getCellStyle().getAlignment()).isEqualTo(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
assertThat(cell.getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
}
}
assertThat(row.getCell(0).getCellStyle().getVerticalAlignment()).isEqualTo(ExcelExportStyleTokens.VERTICAL_ALIGNMENT);
assertThat(workbook.getAllPictures()).isEmpty();
}
verify(addressService, times(3)).getAddressListByUserIds(any());
@@ -133,6 +143,9 @@ class MemberUserExportWriterTest extends BaseMockitoUnitTest {
try (Workbook workbook = WorkbookFactory.create(file.toFile())) {
assertThat(workbook.getSheetAt(0).getLastRowNum()).isZero();
assertThat(workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue()).isEqualTo("用户ID");
assertThat(workbook.getSheetAt(0).getRow(0).getHeightInPoints()).isEqualTo(ExcelExportStyleTokens.ROW_HEIGHT);
assertThat(workbook.getSheetAt(0).getRow(0).getCell(0).getCellStyle().getAlignment())
.isEqualTo(ExcelExportStyleTokens.HEAD_HORIZONTAL_ALIGNMENT);
}
verify(memberUserMapper, never()).selectExportBatch(any(), anyLong(), any(), anyInt());
verifyNoInteractions(addressService, memberRecentTagMapper);
@@ -19,3 +19,15 @@ CREATE TABLE IF NOT EXISTS member_recent_tag (
tag_biz_type varchar(32),
deleted bit NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trade_brokerage_record (
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
biz_type int NOT NULL,
price int NOT NULL,
status int NOT NULL,
title varchar(64),
description varchar(500),
deleted bit NOT NULL DEFAULT FALSE,
tenant_id bigint NOT NULL DEFAULT 0
);