diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/style/ExcelExportStyleTokens.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/style/ExcelExportStyleTokens.java new file mode 100644 index 0000000..a5daf11 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/style/ExcelExportStyleTokens.java @@ -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() { + } +} diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java index ca024da..3032c6d 100644 --- a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtils.java @@ -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 void write(HttpServletResponse response, String filename, String sheetName, Class head, List 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 实现下拉框 diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtilsTest.java new file mode 100644 index 0000000..c6d9fe9 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtilsTest.java @@ -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; + } +} diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java index 8b13c6b..a7fa236 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java @@ -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 getIncomeSummaryMap(Collection userIds); + + /** 按来源会员查询其为指定收款会员产生的收益,金额单位为分。 */ + Map getContributionSummaryMap(Long recipientUserId, + Collection sourceUserIds); + + /** 按业务类型和业务编号批量查询每单实际收益人及各状态佣金。 */ + Map getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType, + Collection bizIds); + /** * 添加朋友圈分销【多级分佣】 * 首次分销才有用 diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageIncomeSummaryRespDTO.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageIncomeSummaryRespDTO.java new file mode 100644 index 0000000..ba7b5d6 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageIncomeSummaryRespDTO.java @@ -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; + +} diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageOrderSummaryRespDTO.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageOrderSummaryRespDTO.java new file mode 100644 index 0000000..798dced --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageOrderSummaryRespDTO.java @@ -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 recipients = new ArrayList<>(); + + @Data + @EqualsAndHashCode(callSuper = true) + public static class Recipient extends BrokerageIncomeSummaryRespDTO { + + private Long userId; + + private Integer sourceUserLevel; + + private Long cancelledPrice = 0L; + } +} diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageIncomeSql.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageIncomeSql.java new file mode 100644 index 0000000..4914482 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageIncomeSql.java @@ -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 '%提现%'"; + } + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java index 2903038..0daade7 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java @@ -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 getIncomeSummaryMap(Collection userIds) { + return brokerageRecordService.getIncomeSummaryMap(userIds); + } + + @Override + public Map getContributionSummaryMap(Long recipientUserId, + Collection sourceUserIds) { + return brokerageRecordService.getContributionSummaryMap(recipientUserId, sourceUserIds); + } + + @Override + public Map getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType, + Collection bizIds) { + return brokerageRecordService.getOrderSummaryMap(bizType, bizIds); + } + /** * 添加朋友圈分销【多级分佣】 * 首次分销才有用 diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java index 36c4744..58c30a4 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/record/BrokerageRecordPageReqVO.java @@ -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; diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeMchDeliveryImportVo.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeMchDeliveryImportVo.java index 3a493cb..84c92e6 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeMchDeliveryImportVo.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeMchDeliveryImportVo.java @@ -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 { diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeOrderListExportVo.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeOrderListExportVo.java index d3b2af3..49e60f9 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeOrderListExportVo.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/order/vo/TradeOrderListExportVo.java @@ -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("订单编号") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java index a17c3bf..872ed35 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageRecordMapper.java @@ -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 { default PageResult selectPage(BrokerageRecordPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .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 selectIncomeSummaryMap(Collection userIds, + Long recipientUserId) { + Map 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 query = new QueryWrapper() + .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 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 selectListByStatusAndUnfreezeTimeLt(Integer status, LocalDateTime unfreezeTime) { @@ -44,6 +82,41 @@ public interface BrokerageRecordMapper extends BaseMapperX { .lt(BrokerageRecordDO::getUnfreezeTime, unfreezeTime)); } + default Map selectOrderSummaryMap(Integer bizType, + Collection bizIds) { + Map result = new LinkedHashMap<>(); + bizIds.forEach(id -> result.put(id, new BrokerageOrderSummaryRespDTO())); + if (bizIds.isEmpty()) { + return result; + } + QueryWrapper query = new QueryWrapper() + .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 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() .eq(BrokerageRecordDO::getId, id) diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java index 43c3a24..8fc5e80 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordService.java @@ -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 getIncomeSummaryMap(Collection userIds); + + Map getContributionSummaryMap(Long recipientUserId, + Collection sourceUserIds); + + Map getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType, + Collection bizIds); + /** * 获得佣金记录 * diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java index 213d951..efe8996 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java @@ -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 getIncomeSummaryMap(Collection userIds) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyMap(); + } + return brokerageRecordMapper.selectIncomeSummaryMap(userIds, null); + } + + @Override + public Map getContributionSummaryMap(Long recipientUserId, + Collection sourceUserIds) { + if (CollUtil.isEmpty(sourceUserIds)) { + return Collections.emptyMap(); + } + Objects.requireNonNull(recipientUserId, "贡献收益的收款会员不能为空"); + return brokerageRecordMapper.selectIncomeSummaryMap(sourceUserIds, recipientUserId); + } + + @Override + public Map getOrderSummaryMap(BrokerageRecordBizTypeEnum bizType, + Collection 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); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java new file mode 100644 index 0000000..79dad65 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageIncomeQueryTest.java @@ -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 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 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 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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + } + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql new file mode 100644 index 0000000..b8b99f0 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_income_schema.sql @@ -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 +); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserController.java index 060e2ad..a844597 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserController.java @@ -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 lastByUserId(@RequestParam("userId") Long userId) { @@ -50,6 +64,31 @@ public class OpenTypeUserController { return success(openTypeService.getOpenTypeUserPage(pageReqVO)); } + @PostMapping("/export-async") + @Operation(summary = "按会员开通记录搜索条件提交分批导出任务") + public CommonResult exportHistory(@Valid OpenTypeUserReqVO query) { + return success(memberUserExportService.createHistory(query)); + } + + @GetMapping("/export-progress") + @Operation(summary = "查询当前登录人的会员开通记录导出进度") + public CommonResult getExportProgress(@RequestParam("taskId") String taskId) { + return success(memberUserExportService.getHistoryProgress(taskId)); + } + + @GetMapping("/export-download") + @Operation(summary = "下载当前登录人的会员开通记录导出文件") + public ResponseEntity 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 = "正在退款中,请稍后重试") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserBrokerageRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserBrokerageRespVO.java new file mode 100644 index 0000000..d8dc307 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserBrokerageRespVO.java @@ -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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserExportRowVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserExportRowVO.java new file mode 100644 index 0000000..a45e726 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserExportRowVO.java @@ -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; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserReqVO.java index 21eb15a..44cc9f7 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserReqVO.java @@ -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 createTimeList; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserRespVO.java index e70f74b..4aa5a58 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/opentype/vo/openTypeUser/OpenTypeUserRespVO.java @@ -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(); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java index 4a9e0d8..48f09eb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java @@ -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 memberRecentTags = memberRecentTagMapper.getByMemberId(result.getId()); if (ObjectUtil.isNotEmpty(memberRecentTags)) { List tags = memberRecentTags.stream().map(v -> { @@ -158,7 +164,7 @@ public class MemberUserController { public CommonResult> getUserPage(@Valid MemberUserPageReqVO pageVO) { PageResult pageResult = memberUserService.getUserPage(pageVO); if (CollUtil.isEmpty(pageResult.getList())) { - return success(PageResult.empty()); + return success(new PageResult<>(Collections.emptyList(), pageResult.getTotal())); } // 处理用户标签返显 Set tagIds = pageResult.getList().stream() @@ -180,6 +186,10 @@ public class MemberUserController { PageResult result = MemberUserConvert.INSTANCE.convertPage( pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses); fillInviters(result.getList()); + Map 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 users = convertMap(memberUserService.getUserList( convertSet(page.getList(), BrokerageUserRespDTO::getId)), MemberUserDO::getId); PageResult result = BeanUtils.toBean(page, MemberUserBrokerageChildRespVO.class); + Set childIds = convertSet(page.getList(), BrokerageUserRespDTO::getId); + Map summaries = tradeBrokerageRecordApi.getIncomeSummaryMap(childIds); + Long contributionUserId = reqVO.getContributionUserId() == null + ? reqVO.getBindUserId() : reqVO.getContributionUserId(); + Map 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 userIds = new HashSet<>(Collections.singleton(user.getId())); + MemberUserInviterRespVO inviter = user.getInviter(); + if (inviter != null) { + userIds.add(inviter.getId()); + } + Map summaries = tradeBrokerageRecordApi.getIncomeSummaryMap(userIds); + user.setBrokerageSummary(summaries.getOrDefault(user.getId(), new BrokerageIncomeSummaryRespDTO())); + if (inviter != null) { + inviter.setBrokerageSummary(summaries.getOrDefault(inviter.getId(), new BrokerageIncomeSummaryRespDTO())); + Map contributions = tradeBrokerageRecordApi.getContributionSummaryMap( + user.getId(), Collections.singleton(inviter.getId())); + inviter.setContributionSummary(contributions.getOrDefault(inviter.getId(), new BrokerageIncomeSummaryRespDTO())); + } + } + private void fillInviters(List users) { List relations = tradeBrokerageUserApi.getBrokerageUserList( convertSet(users, MemberUserRespVO::getId)); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java index 6d77f5e..9713c61 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java @@ -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(); + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java index 8f36771..9509242 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java @@ -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); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java index f90cc7f..c3e4352 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java @@ -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") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java index 505340a..ba80bf2 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java @@ -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(); + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java index b3a90d1..abd03e2 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java @@ -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 芋艿:登录用户类型; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java index 75d8a0a..7722101 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java @@ -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; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapper.java index e33c89a..b8ba282 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapper.java @@ -46,4 +46,14 @@ public interface OpenTypeUserMapper extends BaseMapperX { * 获得会员开通记录分页 */ IPage page(IPage page, @Param("dto") OpenTypeUserReqVO dto); -} \ No newline at end of file + + Long selectExportUpperBound(@Param("dto") OpenTypeUserReqVO dto); + + long selectExportCount(@Param("dto") OpenTypeUserReqVO dto, @Param("upperId") Long upperId); + + List selectExportBatch(@Param("dto") OpenTypeUserReqVO dto, + @Param("upperId") Long upperId, + @Param("lastCreateTime") LocalDateTime lastCreateTime, + @Param("lastId") Long lastId, + @Param("limit") int limit); +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java index 8c7d9b4..d3149a7 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java @@ -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 { .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 + "}") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java index f66e58b..34dcc40 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java @@ -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; } } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeService.java index 1b303a8..55ea25d 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeService.java @@ -60,6 +60,9 @@ public interface OpenTypeService { */ PageResult getOpenTypeUserPage(OpenTypeUserReqVO pageReqVO); + /** 批量填充开通单的实际分销收益,供列表和分批导出共用。 */ + void fillBrokerageSummary(List records); + /** * 获得会员类列表 */ @@ -111,4 +114,4 @@ public interface OpenTypeService { * 退款的回调 */ void updateWalletRechargeRefunded(Long merchantOrderId, Long payRefundId); -} \ No newline at end of file +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java index 4a24adb..6bd0d76 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java @@ -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 getOpenTypeUserPage(OpenTypeUserReqVO pageReqVO) { IPage pageList = openTypeUserMapper.page(new Page(pageReqVO.getPageNo(), pageReqVO.getPageSize()), pageReqVO); List list = pageList.getRecords(); + fillBrokerageSummary(list); PageResult result = new PageResult(); result.setList(list); @@ -204,6 +210,39 @@ public class OpenTypeServiceImpl implements OpenTypeService { return result; } + @Override + public void fillBrokerageSummary(List records) { + if (records.isEmpty()) { + return; + } + List bizIds = records.stream().map(record -> record.getId().toString()).collect(Collectors.toList()); + Map summaries = tradeBrokerageRecordApi.getOrderSummaryMap( + BrokerageRecordBizTypeEnum.OPEN_TYPE, bizIds); + Set recipientIds = summaries.values().stream().flatMap(summary -> summary.getRecipients().stream()) + .map(BrokerageOrderSummaryRespDTO.Recipient::getUserId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map 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); } } -} \ No newline at end of file +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriter.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriter.java new file mode 100644 index 0000000..ecb7b85 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriter.java @@ -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 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 records = openTypeUserMapper.selectExportBatch(query, upperId, + lastCreateTime, lastId, properties.getBatchSize()); + if (records.isEmpty()) { + break; + } + openTypeService.fillBrokerageSummary(records); + List 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()); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java index f5873ca..dd40b62 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java @@ -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; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java index 3980c27..bb023bd 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java @@ -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 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, 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, 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) diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java index c883aa4..f1247bb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java @@ -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) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/OpenTypeUserMapper.xml b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/OpenTypeUserMapper.xml index 374e560..89437eb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/OpenTypeUserMapper.xml +++ b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/OpenTypeUserMapper.xml @@ -31,15 +31,67 @@ and a.member_id = #{dto.memberId} - - and a.pay_status = #{dto.payStatus} + + + + and (a.pay_status = 1 or a.pay_time is not null) + + + and (a.pay_status = 0 or a.pay_status is null) and a.pay_time is null + + - - and a.refund_status = #{dto.refundStatus} + + + + and (a.refund_status is null or a.refund_status = 0) + + + and a.refund_status = #{dto.refundStatus} + + - - and a.createTime between #{dto.createTimeList[0]} and #{dto.createTimeList[1]} + + and u.nickname like concat('%', #{dto.memberName}, '%') + + and u.user_code = #{dto.userCode} + + + and u.mobile like concat('%', #{dto.memberMobile}, '%') + + + and o.title like concat('%', #{dto.title}, '%') + + + and a.pay_time between #{dto.payTime[0]} and #{dto.payTime[1]} + + + and a.refund_time between #{dto.refundTime[0]} and #{dto.refundTime[1]} + + + and a.create_time between #{dto.createTime[0]} and #{dto.createTime[1]} + + + and a.create_time >= #{dto.createTimeList[0]} + and a.create_time < date_add(#{dto.createTimeList[1]}, interval 1 day) + + + + + 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 + + + + , + 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 - , - 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 + + - - and u.nickname like concat('%', #{dto.memberName}, '%') - order by a.create_time desc , a.id desc + + + + + + diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserExportControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserExportControllerTest.java new file mode 100644 index 0000000..4e696d2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/opentype/OpenTypeUserExportControllerTest.java @@ -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 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})); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java index 07f0618..85ad476 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java @@ -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 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 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 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 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); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapperTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapperTest.java new file mode 100644 index 0000000..8ecbd2b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/opentype/OpenTypeUserMapperTest.java @@ -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 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); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java index 212f858..6e0c5f7 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java @@ -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 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"); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java new file mode 100644 index 0000000..d46311d --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserBrokerageTest.java @@ -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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriterTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriterTest.java new file mode 100644 index 0000000..4d986c2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeUserExportWriterTest.java @@ -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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java index 3b8bb0f..c701deb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java @@ -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); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java index 5f4d697..d68d65b 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java @@ -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); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_user_page_filter_schema.sql b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_user_page_filter_schema.sql index bc0c09e..df44efc 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_user_page_filter_schema.sql +++ b/yudao-module-member/yudao-module-member-biz/src/test/resources/sql/member_user_page_filter_schema.sql @@ -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 +);