feat:会员管理优化增加导出
This commit is contained in:
1 parent
e04e1ca08b
commit
e4b079279b
23 files changed
+1576
-12
No files matched your search
@@ -0,0 +1,42 @@
|
||||
# 会员用户分批导出
|
||||
|
||||
会员列表、男号管理和女号管理共用导出入口。导出取点击时搜索表单的快照,忽略分页和表格勾选,导出全部匹配用户。后端列表、导出计数和导出批次复用 `MemberUserMapper.buildUserQuery`,保留列表全部筛选条件。
|
||||
|
||||
- `POST /admin-api/member/user/export-async`:查询参数与 `/member/user/page` 相同,包含索引格式的标签数组和时间范围;返回任务 ID。
|
||||
- `GET /admin-api/member/user/export-progress?taskId=...`:返回等待、处理中、成功或失败状态、条数和百分比。
|
||||
- `GET /admin-api/member/user/export-download?taskId=...`:鉴权后从磁盘流式下载 Excel。
|
||||
|
||||
三个接口沿用 `member:user:query` 权限,任务按登录人和租户隔离。文件不上传到公开文件存储。每个用户同一时间只运行一个任务,重复提交返回已有任务,并由前端明确提示本次条件未重新提交。前端每两秒查询一次,查询失败后可重试;刷新页面可通过 sessionStorage 中按租户及用户隔离的任务 ID 恢复。
|
||||
|
||||
默认每批 500 人,按 ID 倒序游标读取;开始处理时固定最大 ID,后续新增用户不进入本次任务。地址、标签只按本批用户批量查询。EasyExcel 使用磁盘临时文件,每个 Sheet 最多 100 万行,超出自动新建 Sheet。Excel 不导出内部“用户编号”,保留 App 展示的“用户ID”。手机号和用户 ID 使用文本单元格,保留前导零。零结果生成包含列头的空表。文件关闭并就绪后进度才置为 100%。
|
||||
|
||||
导出列为:用户ID、性别、手机号、昵称、用户地址、微信号、用户标签、是否禁用、登录时间、注册时间。头像、头像地址和微信二维码均不导出;导出查询不读取图片字段,也不下载、解码或打包任何图片。数据以批次写入 Excel,每批更新进度;最后生成文件期间保持 99%,文件可下载后变为 100%。数据行高为 24 pt。
|
||||
|
||||
配置项均有默认值,无需修改现有业务配置:
|
||||
|
||||
```yaml
|
||||
yudao:
|
||||
member:
|
||||
user-export:
|
||||
directory: /data/member-user-export
|
||||
batch-size: 500
|
||||
rows-per-sheet: 1000000
|
||||
retention-hours: 24
|
||||
workers: 2
|
||||
queue-capacity: 8
|
||||
```
|
||||
|
||||
单实例默认使用 JVM 临时目录下的 `yudao-member-user-export`。**多实例部署必须将 `directory` 配为所有实例可读写的同一个共享目录**,确保负载均衡后的下载请求可读取文件;任务进度、用户锁和实例心跳使用现有 Redis。为该目录预留大文件和 Excel 临时文件所需磁盘空间。任务默认 24 小时过期,超期文件每小时清理一次;临时目录也可能被系统或容器清理,因此前端提示及时下载,不承诺 24 小时内始终可用。服务重启后,失去心跳的任务在两分钟后可被标记为失败并重试。
|
||||
|
||||
导出过程中用户资料、标签或地址被修改时,后续批次按当时数据判断条件;预计总数可能变化,完成时更新为实际导出条数。没有开启长事务或全量数据快照。
|
||||
|
||||
相关验证命令:
|
||||
|
||||
```bash
|
||||
# 后端项目根目录,包含 256 MB 堆下十万行纯文本导出与 SAX 行数校验
|
||||
mvn -pl yudao-module-member/yudao-module-member-biz -am -Dtest=MemberUserMapperTest,MemberUserExportWriterTest,MemberUserExportServiceTest,MemberUserExportControllerTest -Dsurefire.failIfNoSpecifiedTests=false -DargLine=-Xmx256m -Dflatten.skip=true test
|
||||
|
||||
# Friendship-and-chat-pc 项目根目录
|
||||
node --test tests/member-user-export.test.cjs
|
||||
npm run build:prod
|
||||
```
|
||||
+47
-1
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.*;
|
||||
import cn.iocoder.yudao.module.member.convert.user.MemberUserConvert;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.group.MemberGroupDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.level.MemberLevelDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.tag.MemberTagDO;
|
||||
@@ -14,16 +15,23 @@ import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
|
||||
import cn.iocoder.yudao.module.member.enums.point.MemberPointBizTypeEnum;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressService;
|
||||
import cn.iocoder.yudao.module.member.service.group.MemberGroupService;
|
||||
import cn.iocoder.yudao.module.member.service.level.MemberLevelService;
|
||||
import cn.iocoder.yudao.module.member.service.point.MemberPointRecordService;
|
||||
import cn.iocoder.yudao.module.member.service.tag.MemberTagService;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -31,6 +39,10 @@ import javax.annotation.Resource;
|
||||
import javax.annotation.security.PermitAll;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.AssertFalse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -50,6 +62,8 @@ public class MemberUserController {
|
||||
@Resource
|
||||
private MemberUserService memberUserService;
|
||||
@Resource
|
||||
private MemberUserExportService memberUserExportService;
|
||||
@Resource
|
||||
private MemberTagService memberTagService;
|
||||
@Resource
|
||||
private MemberLevelService memberLevelService;
|
||||
@@ -62,6 +76,8 @@ public class MemberUserController {
|
||||
private MemberRecentTagMapper memberRecentTagMapper;
|
||||
@Resource
|
||||
private MemberRecentCfgService memberRecentCfgService;
|
||||
@Resource
|
||||
private AddressService addressService;
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新会员用户")
|
||||
@@ -150,7 +166,37 @@ public class MemberUserController {
|
||||
List<MemberRecentTagDO> memberRecentTags = memberRecentTagMapper.getByIds(convertSet(pageResult.getList(), MemberUserDO::getId));
|
||||
// 查询用户配置
|
||||
List<MemberRecentCfgDO> memberCfgList = memberRecentCfgService.getByMemberIds(convertSet(pageResult.getList(), MemberUserDO::getId));
|
||||
return success(MemberUserConvert.INSTANCE.convertPage(pageResult, tags, memberRecentTags, levels, groups, memberCfgList));
|
||||
// 按本页用户批量查询收货地址,避免逐个用户查询。
|
||||
List<MemberAddressDO> addresses = addressService.getAddressListByUserIds(convertSet(pageResult.getList(), MemberUserDO::getId));
|
||||
return success(MemberUserConvert.INSTANCE.convertPage(pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses));
|
||||
}
|
||||
|
||||
@PostMapping("/export-async")
|
||||
@Operation(summary = "按会员列表搜索条件提交分批导出任务")
|
||||
@PreAuthorize("@ss.hasPermission('member:user:query')")
|
||||
public CommonResult<MemberUserExportRespVO> exportUsers(@Valid MemberUserPageReqVO query) {
|
||||
return success(memberUserExportService.create(query));
|
||||
}
|
||||
|
||||
@GetMapping("/export-progress")
|
||||
@Operation(summary = "查询当前登录人提交的会员导出进度")
|
||||
@PreAuthorize("@ss.hasPermission('member:user:query')")
|
||||
public CommonResult<MemberUserExportRespVO> getExportProgress(@RequestParam("taskId") String taskId) {
|
||||
return success(memberUserExportService.getProgress(taskId));
|
||||
}
|
||||
|
||||
@GetMapping("/export-download")
|
||||
@Operation(summary = "下载当前登录人的会员导出文件")
|
||||
@PreAuthorize("@ss.hasPermission('member:user:query')")
|
||||
public ResponseEntity<FileSystemResource> downloadExport(@RequestParam("taskId") String taskId) throws IOException {
|
||||
Path file = memberUserExportService.getDownloadFile(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));
|
||||
}
|
||||
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "管理后台 - 会员用户导出进度")
|
||||
public class MemberUserExportRespVO {
|
||||
|
||||
@Schema(description = "导出任务编号")
|
||||
private String taskId;
|
||||
|
||||
@Schema(description = "任务状态:WAITING、RUNNING、SUCCESS、FAILURE")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "符合筛选条件的预计总条数,完成时为实际导出条数")
|
||||
private long total;
|
||||
|
||||
@Schema(description = "已写入条数")
|
||||
private long processed;
|
||||
|
||||
@Schema(description = "进度,0-100;文件生成完成后才返回 100")
|
||||
private int progress;
|
||||
|
||||
@Schema(description = "状态说明")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "下载文件名")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "是否复用当前登录人已有的进行中任务,本次筛选条件未重新提交")
|
||||
private boolean existingTask;
|
||||
|
||||
@Schema(description = "文件保留小时数")
|
||||
private int retentionHours;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
|
||||
|
||||
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;
|
||||
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@ColumnWidth(22)
|
||||
@HeadRowHeight(25)
|
||||
@ContentRowHeight(24)
|
||||
@ContentStyle(verticalAlignment = VerticalAlignmentEnum.CENTER)
|
||||
public class MemberUserExportRowVO {
|
||||
|
||||
@ExcelProperty("用户ID")
|
||||
private String userCode;
|
||||
@ExcelProperty("性别")
|
||||
@ColumnWidth(10)
|
||||
private String sex;
|
||||
@ExcelProperty("手机号")
|
||||
private String mobile;
|
||||
@ExcelProperty("昵称")
|
||||
private String nickname;
|
||||
@ExcelProperty("用户地址")
|
||||
@ColumnWidth(50)
|
||||
private String addressAreaNames;
|
||||
@ExcelProperty("微信号")
|
||||
private String wechatNum;
|
||||
@ExcelProperty("用户标签")
|
||||
@ColumnWidth(35)
|
||||
private String memberTags;
|
||||
@ExcelProperty("是否禁用")
|
||||
@ColumnWidth(12)
|
||||
private String status;
|
||||
@ExcelProperty("登录时间")
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime loginDate;
|
||||
@ExcelProperty("注册时间")
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
+6
@@ -21,6 +21,12 @@ public class MemberUserPageReqVO extends PageParam {
|
||||
@Schema(description = "手机号", example = "15601691300")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "微信号,支持模糊匹配", example = "wx_user")
|
||||
private String wechatNum;
|
||||
|
||||
@Schema(description = "收货地址地区编号,支持选择省、市、区,匹配任一有效地址及其下级地区", example = "330106")
|
||||
private Integer addressAreaId;
|
||||
|
||||
@Schema(description = "用户昵称", example = "李四")
|
||||
private String nickname;
|
||||
|
||||
|
||||
+9
@@ -22,6 +22,15 @@ public class MemberUserRespVO extends MemberUserBaseVO {
|
||||
@Schema(description = "用户 Code,6 位数字和大小写字母组合", example = "aB3xY9")
|
||||
private String userCode;
|
||||
|
||||
@Schema(description = "微信号", example = "wx_user")
|
||||
private String wechatNum;
|
||||
|
||||
@Schema(description = "微信二维码图片地址", example = "https://example.com/wechat.png")
|
||||
private String wechatQrCode;
|
||||
|
||||
@Schema(description = "收货地址地区列表,省-市-区格式,同一地区去重", example = "[\"浙江省-杭州市-西湖区\"]")
|
||||
private List<String> addressAreaNames;
|
||||
|
||||
@Schema(description = "注册 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "127.0.0.1")
|
||||
private String registerIp;
|
||||
|
||||
|
||||
+15
-1
@@ -2,11 +2,13 @@ package cn.iocoder.yudao.module.member.convert.user;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils;
|
||||
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserRespVO;
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserUpdateReqVO;
|
||||
import cn.iocoder.yudao.module.member.controller.app.user.vo.AppMemberUserInfoRespVO;
|
||||
import cn.iocoder.yudao.module.member.convert.address.AddressConvert;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.group.MemberGroupDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.level.MemberLevelDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.tag.MemberTagDO;
|
||||
@@ -19,8 +21,10 @@ import org.mapstruct.Mappings;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
|
||||
@@ -56,7 +60,8 @@ public interface MemberUserConvert {
|
||||
List<MemberRecentTagDO> memberRecentTags,
|
||||
List<MemberLevelDO> levels,
|
||||
List<MemberGroupDO> groups,
|
||||
List<MemberRecentCfgDO> memberCfgList
|
||||
List<MemberRecentCfgDO> memberCfgList,
|
||||
List<MemberAddressDO> addresses
|
||||
) {
|
||||
PageResult<MemberUserRespVO> result = convertPage(pageResult);
|
||||
// 处理关联数据
|
||||
@@ -64,12 +69,21 @@ public interface MemberUserConvert {
|
||||
Map<Long, String> levelMap = convertMap(levels, MemberLevelDO::getId, MemberLevelDO::getName);
|
||||
Map<Long, String> groupMap = convertMap(groups, MemberGroupDO::getId, MemberGroupDO::getName);
|
||||
Map<Long, MemberRecentCfgDO> memberCfgMap = convertMap(memberCfgList, MemberRecentCfgDO::getMemberId);
|
||||
Map<Long, List<MemberAddressDO>> addressMap = convertMultiMap(addresses, MemberAddressDO::getUserId);
|
||||
// 填充关联数据
|
||||
result.getList().forEach(user -> {
|
||||
user.setTagNames(convertList(user.getTagIds(), tagMap::get));
|
||||
user.setLevelName(levelMap.get(user.getLevelId()));
|
||||
user.setGroupName(groupMap.get(user.getGroupId()));
|
||||
user.setMemberTagList(new ArrayList<>());
|
||||
user.setAddressAreaNames(addressMap.getOrDefault(user.getId(), Collections.emptyList()).stream()
|
||||
.map(MemberAddressDO::getAreaId)
|
||||
.filter(areaId -> areaId != null && areaId > 0 && areaId <= Integer.MAX_VALUE)
|
||||
.map(areaId -> AreaUtils.format(areaId.intValue(), "-"))
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
MemberRecentCfgDO cfg = memberCfgMap.get(user.getId());
|
||||
if (ObjectUtil.isEmpty(cfg)) {
|
||||
|
||||
+54
-4
@@ -4,6 +4,8 @@ import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.ip.core.Area;
|
||||
import cn.iocoder.yudao.framework.ip.core.utils.AreaUtils;
|
||||
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;
|
||||
@@ -13,6 +15,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
@@ -79,6 +82,11 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
||||
}
|
||||
|
||||
default PageResult<MemberUserDO> selectPage(MemberUserPageReqVO reqVO) {
|
||||
return selectPage(reqVO, buildUserQuery(reqVO).orderByDesc(MemberUserDO::getId));
|
||||
}
|
||||
|
||||
/** 列表、导出计数和游标批次共用筛选条件,避免新增搜索项时遗漏导出。 */
|
||||
default LambdaQueryWrapper<MemberUserDO> buildUserQuery(MemberUserPageReqVO reqVO) {
|
||||
// 处理 tagIds 过滤条件
|
||||
String tagIdSql = "";
|
||||
if (CollUtil.isNotEmpty(reqVO.getTagIds())) {
|
||||
@@ -86,9 +94,9 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
||||
.map(tagId -> "FIND_IN_SET(" + tagId + ", tag_ids)")
|
||||
.collect(Collectors.joining(" OR ", "(", ")"));
|
||||
}
|
||||
// 分页查询
|
||||
LambdaQueryWrapper<MemberUserDO> query = new LambdaQueryWrapperX<MemberUserDO>()
|
||||
.likeIfPresent(MemberUserDO::getMobile, reqVO.getMobile())
|
||||
.likeIfPresent(MemberUserDO::getWechatNum, StrUtil.trim(reqVO.getWechatNum()))
|
||||
.betweenIfPresent(MemberUserDO::getLoginDate, reqVO.getLoginDate())
|
||||
.likeIfPresent(MemberUserDO::getNickname, reqVO.getNickname())
|
||||
.betweenIfPresent(MemberUserDO::getCreateTime, reqVO.getCreateTime())
|
||||
@@ -97,8 +105,7 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
||||
.eqIfPresent(MemberUserDO::getUserCode, reqVO.getUserCode())
|
||||
.eqIfPresent(MemberUserDO::getSex, reqVO.getSex())
|
||||
.eqIfPresent(MemberUserDO::getStatus, reqVO.getStatus())
|
||||
.apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql)
|
||||
.orderByDesc(MemberUserDO::getId);
|
||||
.apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql);
|
||||
if (CollUtil.isNotEmpty(reqVO.getMemberRecentTagList())) {
|
||||
String placeholders = IntStream.range(0, reqVO.getMemberRecentTagList().size())
|
||||
.mapToObj(index -> "{" + index + "}")
|
||||
@@ -108,7 +115,50 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
||||
+ " AND tag.deleted = 0 AND tag.tag_biz_type IN (" + placeholders + ")",
|
||||
reqVO.getMemberRecentTagList().toArray());
|
||||
}
|
||||
return selectPage(reqVO, query);
|
||||
if (reqVO.getAddressAreaId() != null) {
|
||||
Area selectedArea = AreaUtils.getArea(reqVO.getAddressAreaId());
|
||||
if (selectedArea == null) {
|
||||
query.apply("1 = 0");
|
||||
} else {
|
||||
// 选择省或市时包含下级地区,不依赖行政区编码的前缀规则。
|
||||
List<Area> areas = new ArrayList<>();
|
||||
areas.add(selectedArea);
|
||||
for (int index = 0; index < areas.size(); index++) {
|
||||
areas.addAll(areas.get(index).getChildren());
|
||||
}
|
||||
String placeholders = IntStream.range(0, areas.size())
|
||||
.mapToObj(index -> "{" + index + "}")
|
||||
.collect(Collectors.joining(", "));
|
||||
// 匹配任一未删除的收货地址;EXISTS 避免一人多地址导致重复分页。
|
||||
query.exists("SELECT 1 FROM member_address address WHERE address.user_id = member_user.id"
|
||||
+ " AND address.deleted = 0 AND address.area_id IN (" + placeholders + ")",
|
||||
areas.stream().map(Area::getId).toArray());
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
default MemberUserDO selectExportUpperBound(MemberUserPageReqVO reqVO) {
|
||||
return selectOne(buildUserQuery(reqVO).select(MemberUserDO::getId)
|
||||
.orderByDesc(MemberUserDO::getId).last("LIMIT 1"));
|
||||
}
|
||||
|
||||
default Long selectExportCount(MemberUserPageReqVO reqVO, Long maxId) {
|
||||
return selectCount(buildUserQuery(reqVO).le(MemberUserDO::getId, maxId));
|
||||
}
|
||||
|
||||
/** 固定本次导出的最大 ID,后续按 ID 向前扫描,避免深分页和新增数据挤动分页。 */
|
||||
default List<MemberUserDO> selectExportBatch(MemberUserPageReqVO reqVO, Long maxId,
|
||||
Long lastId, int batchSize) {
|
||||
Assert.isTrue(batchSize > 0 && batchSize <= 10000, "导出批次大小不合法");
|
||||
return selectList(buildUserQuery(reqVO)
|
||||
.select(MemberUserDO::getId, MemberUserDO::getUserCode, MemberUserDO::getSex,
|
||||
MemberUserDO::getMobile, MemberUserDO::getNickname,
|
||||
MemberUserDO::getWechatNum, MemberUserDO::getStatus,
|
||||
MemberUserDO::getLoginDate, MemberUserDO::getCreateTime)
|
||||
.le(MemberUserDO::getId, maxId)
|
||||
.lt(lastId != null, MemberUserDO::getId, lastId)
|
||||
.orderByDesc(MemberUserDO::getId).last("LIMIT " + batchSize));
|
||||
}
|
||||
|
||||
default Long selectCountByGroupId(Long groupId) {
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package cn.iocoder.yudao.module.member.dal.redis.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
|
||||
import cn.iocoder.yudao.module.member.service.user.BO.MemberUserExportTask;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
|
||||
@Repository
|
||||
public class MemberUserExportRedisDAO {
|
||||
|
||||
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class);
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Resource
|
||||
private MemberUserExportProperties properties;
|
||||
|
||||
public void save(MemberUserExportTask task) {
|
||||
task.setUpdatedAt(System.currentTimeMillis());
|
||||
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()));
|
||||
}
|
||||
|
||||
public MemberUserExportTask get(Long tenantId, Long userId, String taskId) {
|
||||
return JsonUtils.parseObject(stringRedisTemplate.opsForValue().get(taskKey(tenantId, userId, taskId)),
|
||||
MemberUserExportTask.class);
|
||||
}
|
||||
|
||||
public boolean acquire(Long tenantId, Long userId, String taskId) {
|
||||
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(activeKey(tenantId, userId), taskId,
|
||||
Duration.ofHours(properties.getRetentionHours())));
|
||||
}
|
||||
|
||||
public String getActiveTaskId(Long tenantId, Long userId) {
|
||||
return stringRedisTemplate.opsForValue().get(activeKey(tenantId, userId));
|
||||
}
|
||||
|
||||
public void release(MemberUserExportTask task) {
|
||||
stringRedisTemplate.execute(RELEASE_SCRIPT, Collections.singletonList(activeKey(task.getTenantId(), task.getUserId())),
|
||||
task.getTaskId());
|
||||
}
|
||||
|
||||
public void heartbeat(String workerId) {
|
||||
stringRedisTemplate.opsForValue().set("member:user:export:worker:" + workerId, "1", Duration.ofMinutes(2));
|
||||
}
|
||||
|
||||
public boolean isWorkerAlive(String workerId) {
|
||||
return Boolean.TRUE.equals(stringRedisTemplate.hasKey("member:user:export:worker:" + workerId));
|
||||
}
|
||||
|
||||
private String taskKey(Long tenantId, Long userId, String taskId) {
|
||||
return "member:user:export:task:" + tenantId + ":" + userId + ":" + taskId;
|
||||
}
|
||||
|
||||
private String activeKey(Long tenantId, Long userId) {
|
||||
return "member:user:export:active:" + tenantId + ":" + userId;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package cn.iocoder.yudao.module.member.framework.export.config;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(MemberUserExportProperties.class)
|
||||
public class MemberUserExportConfiguration {
|
||||
|
||||
@Bean("memberUserExportExecutor")
|
||||
public ThreadPoolTaskExecutor memberUserExportExecutor(MemberUserExportProperties properties) {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(properties.getWorkers());
|
||||
executor.setMaxPoolSize(properties.getWorkers());
|
||||
executor.setQueueCapacity(properties.getQueueCapacity());
|
||||
executor.setThreadNamePrefix("member-user-export-");
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(30);
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.member.framework.export.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Data
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "yudao.member.user-export")
|
||||
public class MemberUserExportProperties {
|
||||
|
||||
/** 多实例部署时配置为所有实例可访问的同一共享目录。文件仅通过鉴权接口下载。 */
|
||||
@NotBlank
|
||||
private String directory = Paths.get(System.getProperty("java.io.tmpdir"), "yudao-member-user-export").toString();
|
||||
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
private int batchSize = 500;
|
||||
|
||||
@Min(1)
|
||||
@Max(1048575)
|
||||
private int rowsPerSheet = 1000000;
|
||||
|
||||
@Min(1)
|
||||
@Max(168)
|
||||
private int retentionHours = 24;
|
||||
|
||||
@Min(1)
|
||||
@Max(8)
|
||||
private int workers = 2;
|
||||
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
private int queueCapacity = 8;
|
||||
|
||||
}
|
||||
+9
@@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.member.controller.app.address.vo.AppAddressUpdate
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,14 @@ public interface AddressService {
|
||||
*/
|
||||
List<MemberAddressDO> getAddressList(Long userId);
|
||||
|
||||
/**
|
||||
* 批量获得用户收件地址,用于会员列表展示。
|
||||
*
|
||||
* @param userIds 用户编号集合
|
||||
* @return 未删除的用户收件地址列表
|
||||
*/
|
||||
List<MemberAddressDO> getAddressListByUserIds(Collection<Long> userIds);
|
||||
|
||||
/**
|
||||
* 获得用户默认的收件地址
|
||||
*
|
||||
|
||||
+6
@@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
@@ -88,6 +89,11 @@ public class AddressServiceImpl implements AddressService {
|
||||
return memberAddressMapper.selectListByUserIdAndDefaulted(userId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MemberAddressDO> getAddressListByUserIds(Collection<Long> userIds) {
|
||||
return memberAddressMapper.selectList(MemberAddressDO::getUserId, userIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MemberAddressDO getDefaultUserAddress(Long userId) {
|
||||
List<MemberAddressDO> addresses = memberAddressMapper.selectListByUserIdAndDefaulted(userId, true);
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package cn.iocoder.yudao.module.member.service.user.BO;
|
||||
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserExportRespVO;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MemberUserExportTask extends MemberUserExportRespVO {
|
||||
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private String workerId;
|
||||
private long updatedAt;
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
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.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 lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MemberUserExportService {
|
||||
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
private final Set<String> liveWorkers = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Resource
|
||||
private MemberUserExportRedisDAO exportRedisDAO;
|
||||
@Resource
|
||||
private MemberUserExportWriter exportWriter;
|
||||
@Resource
|
||||
private MemberUserExportProperties properties;
|
||||
@Resource(name = "memberUserExportExecutor")
|
||||
private Executor executor;
|
||||
|
||||
public MemberUserExportRespVO create(MemberUserPageReqVO query) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (userId == null) {
|
||||
throw invalidParamException("请登录后再导出");
|
||||
}
|
||||
MemberUserExportTask task = new MemberUserExportTask();
|
||||
task.setTaskId(UUID.randomUUID().toString());
|
||||
task.setUserId(userId);
|
||||
task.setTenantId(tenantId);
|
||||
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);
|
||||
if (activeId == null) {
|
||||
throw invalidParamException("导出任务状态正在更新,请重试");
|
||||
}
|
||||
MemberUserExportTask active = getOwnedTask(activeId);
|
||||
if (isActive(active)) {
|
||||
MemberUserExportRespVO response = toResponse(active);
|
||||
response.setExistingTask(true);
|
||||
return response;
|
||||
}
|
||||
throw invalidParamException("上次导出任务已结束,请重新提交导出");
|
||||
}
|
||||
MemberUserExportRespVO submitted = toResponse(task);
|
||||
liveWorkers.add(task.getWorkerId());
|
||||
try {
|
||||
exportRedisDAO.heartbeat(task.getWorkerId());
|
||||
exportRedisDAO.save(task);
|
||||
// 显式传递登录人和租户,后台查询仍经过原有租户及数据权限拦截器。
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(SecurityFrameworkUtils.getAuthentication());
|
||||
executor.execute(new DelegatingSecurityContextRunnable(
|
||||
() -> TenantUtils.execute(tenantId, () -> runExport(task, query)), context));
|
||||
} catch (RejectedExecutionException exception) {
|
||||
liveWorkers.remove(task.getWorkerId());
|
||||
fail(task, "当前导出任务较多,请稍后重试");
|
||||
exportRedisDAO.release(task);
|
||||
throw invalidParamException("当前导出任务较多,请稍后重试");
|
||||
} catch (RuntimeException exception) {
|
||||
liveWorkers.remove(task.getWorkerId());
|
||||
exportRedisDAO.release(task);
|
||||
throw exception;
|
||||
}
|
||||
return submitted;
|
||||
}
|
||||
|
||||
public MemberUserExportRespVO getProgress(String taskId) {
|
||||
return toResponse(getOwnedTask(taskId));
|
||||
}
|
||||
|
||||
public Path getDownloadFile(String taskId) {
|
||||
MemberUserExportTask task = getOwnedTask(taskId);
|
||||
if (!"SUCCESS".equals(task.getStatus())) {
|
||||
throw invalidParamException("导出文件尚未生成,请等待任务完成");
|
||||
}
|
||||
Path file = filePath(taskId, ".xlsx");
|
||||
if (!Files.isRegularFile(file)) {
|
||||
throw invalidParamException("导出文件已过期或不可用,请重新导出");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private void runExport(MemberUserExportTask task, MemberUserPageReqVO query) {
|
||||
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) -> {
|
||||
task.setTotal(total);
|
||||
task.setProcessed(count);
|
||||
task.setProgress(total == 0 ? 0 : (int) Math.min(99, count * 100 / total));
|
||||
task.setMessage(count >= total ? "数据已写入,正在生成文件" : "正在分批导出");
|
||||
exportRedisDAO.save(task);
|
||||
});
|
||||
Files.move(partial, filePath(task.getTaskId(), ".xlsx"), StandardCopyOption.REPLACE_EXISTING);
|
||||
task.setTotal(processed);
|
||||
task.setProcessed(processed);
|
||||
task.setProgress(100);
|
||||
task.setStatus("SUCCESS");
|
||||
task.setMessage(processed == 0 ? "没有符合条件的数据,已生成空表" : "导出完成,可以下载 Excel");
|
||||
exportRedisDAO.save(task);
|
||||
} catch (Exception exception) {
|
||||
log.error("[runExport][会员导出失败,taskId={}]", task.getTaskId(), exception);
|
||||
deleteFile(partial);
|
||||
deleteFile(filePath(task.getTaskId(), ".xlsx"));
|
||||
fail(task, "导出失败,请稍后重试");
|
||||
} finally {
|
||||
liveWorkers.remove(task.getWorkerId());
|
||||
exportRedisDAO.release(task);
|
||||
}
|
||||
}
|
||||
|
||||
private MemberUserExportTask getOwnedTask(String taskId) {
|
||||
if (taskId == null || !taskId.matches("[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}")) {
|
||||
throw invalidParamException("导出任务不存在或已过期,请重新导出");
|
||||
}
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
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())) {
|
||||
throw invalidParamException("导出任务不存在或已过期,请重新导出");
|
||||
}
|
||||
if (isActive(task) && System.currentTimeMillis() - task.getUpdatedAt() > TimeUnit.MINUTES.toMillis(2)
|
||||
&& !exportRedisDAO.isWorkerAlive(task.getWorkerId())) {
|
||||
fail(task, "导出服务已重启或任务中断,请重新导出");
|
||||
exportRedisDAO.release(task);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private boolean isActive(MemberUserExportTask task) {
|
||||
return "WAITING".equals(task.getStatus()) || "RUNNING".equals(task.getStatus());
|
||||
}
|
||||
|
||||
private void fail(MemberUserExportTask task, String message) {
|
||||
task.setStatus("FAILURE");
|
||||
task.setMessage(message);
|
||||
exportRedisDAO.save(task);
|
||||
}
|
||||
|
||||
private MemberUserExportRespVO toResponse(MemberUserExportTask task) {
|
||||
MemberUserExportRespVO response = BeanUtils.toBean(task, MemberUserExportRespVO.class);
|
||||
response.setRetentionHours(properties.getRetentionHours());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Path filePath(String taskId, String suffix) {
|
||||
return Paths.get(properties.getDirectory()).toAbsolutePath().normalize().resolve(taskId + suffix);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30000, initialDelay = 30000)
|
||||
public void heartbeat() {
|
||||
liveWorkers.forEach(exportRedisDAO::heartbeat);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 3600000, initialDelay = 3600000)
|
||||
public void cleanExpiredFiles() {
|
||||
Path directory = Paths.get(properties.getDirectory());
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
long cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(properties.getRetentionHours());
|
||||
try (Stream<Path> files = Files.list(directory)) {
|
||||
files.filter(file -> file.getFileName().toString().matches("[a-f0-9-]{36}\\.(xlsx|part)")
|
||||
|| file.getFileName().toString().matches("member-export-images-[0-9-]+"))
|
||||
.forEach(file -> {
|
||||
try {
|
||||
if (Files.getLastModifiedTime(file).toMillis() < cutoff) {
|
||||
if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
// 兼容清理旧版图片导出遗留的临时目录。
|
||||
deleteTemporaryDirectory(file);
|
||||
} else {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
log.warn("[cleanExpiredFiles][清理会员导出文件失败,file={}]", file, exception);
|
||||
}
|
||||
});
|
||||
} catch (IOException exception) {
|
||||
log.warn("[cleanExpiredFiles][读取会员导出目录失败]", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteFile(Path file) {
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException exception) {
|
||||
log.warn("[deleteFile][删除会员导出文件失败,file={}]", file, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTemporaryDirectory(Path directory) throws IOException {
|
||||
Files.walkFileTree(directory, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path folder, IOException exception) throws IOException {
|
||||
if (exception != null) throw exception;
|
||||
Files.deleteIfExists(folder);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
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;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressService;
|
||||
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
|
||||
import cn.iocoder.yudao.module.system.api.dict.dto.DictDataRespDTO;
|
||||
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.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
@Component
|
||||
public class MemberUserExportWriter {
|
||||
|
||||
@Resource
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Resource
|
||||
private MemberRecentTagMapper memberRecentTagMapper;
|
||||
@Resource
|
||||
private AddressService addressService;
|
||||
@Resource
|
||||
private DictDataApi dictDataApi;
|
||||
@Resource
|
||||
private MemberUserExportProperties properties;
|
||||
|
||||
/** 仅保留当前批次的数据;EasyExcel 使用磁盘临时文件,并在达到单表上限时切换 Sheet。 */
|
||||
public long write(Path file, MemberUserPageReqVO query, BiConsumer<Long, Long> progress) {
|
||||
MemberUserDO upperBound = memberUserMapper.selectExportUpperBound(query);
|
||||
long total = upperBound == null ? 0 : memberUserMapper.selectExportCount(query, upperBound.getId());
|
||||
progress.accept(total, 0L);
|
||||
Map<String, String> tagLabels = dictDataApi.getDictDataList("member_recent_tag").stream()
|
||||
.collect(Collectors.toMap(DictDataRespDTO::getValue, DictDataRespDTO::getLabel, (first, next) -> first));
|
||||
long processed = 0;
|
||||
Long lastId = null;
|
||||
int sheetIndex = 0;
|
||||
int sheetRows = 0;
|
||||
try (ExcelWriter writer = EasyExcel.write(file.toFile(), MemberUserExportRowVO.class).inMemory(false).build()) {
|
||||
WriteSheet sheet = newSheet(sheetIndex);
|
||||
writer.write(Collections.emptyList(), sheet); // 零结果也生成包含列头的合法文件。
|
||||
while (upperBound != null) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IllegalStateException("导出线程已中断");
|
||||
}
|
||||
List<MemberUserDO> users = memberUserMapper.selectExportBatch(query, upperBound.getId(), lastId,
|
||||
properties.getBatchSize());
|
||||
if (users.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
List<MemberUserExportRowVO> rows = buildRows(users, tagLabels);
|
||||
int offset = 0;
|
||||
while (offset < rows.size()) {
|
||||
if (sheetRows == properties.getRowsPerSheet()) {
|
||||
sheet = newSheet(++sheetIndex);
|
||||
sheetRows = 0;
|
||||
}
|
||||
int end = Math.min(rows.size(), offset + properties.getRowsPerSheet() - sheetRows);
|
||||
writer.write(rows.subList(offset, end), sheet);
|
||||
sheetRows += end - offset;
|
||||
offset = end;
|
||||
}
|
||||
lastId = users.get(users.size() - 1).getId();
|
||||
processed += users.size();
|
||||
progress.accept(Math.max(total, processed), processed);
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
private WriteSheet newSheet(int index) {
|
||||
return EasyExcel.writerSheet(index, "会员用户" + (index + 1)).build();
|
||||
}
|
||||
|
||||
private List<MemberUserExportRowVO> buildRows(List<MemberUserDO> users, Map<String, String> tagLabels) {
|
||||
Set<Long> userIds = convertSet(users, MemberUserDO::getId);
|
||||
Map<Long, List<MemberAddressDO>> addresses = addressService.getAddressListByUserIds(userIds).stream()
|
||||
.collect(Collectors.groupingBy(MemberAddressDO::getUserId));
|
||||
Map<Long, List<MemberRecentTagDO>> tags = memberRecentTagMapper.getByIds(userIds).stream()
|
||||
.collect(Collectors.groupingBy(MemberRecentTagDO::getMemberId));
|
||||
List<MemberUserExportRowVO> rows = new ArrayList<>(users.size());
|
||||
for (MemberUserDO user : users) {
|
||||
MemberUserExportRowVO row = new MemberUserExportRowVO();
|
||||
// 字符串写入可保留手机号/用户 ID 的前导零;文本不会被当作 Excel 公式。
|
||||
row.setUserCode(user.getUserCode());
|
||||
row.setSex(Objects.equals(user.getSex(), 1) ? "男" : Objects.equals(user.getSex(), 2) ? "女" : "未知");
|
||||
row.setMobile(user.getMobile());
|
||||
row.setNickname(user.getNickname());
|
||||
row.setWechatNum(user.getWechatNum());
|
||||
row.setStatus(Objects.equals(user.getStatus(), 1) ? "已禁用" : "未禁用");
|
||||
row.setLoginDate(user.getLoginDate());
|
||||
row.setCreateTime(user.getCreateTime());
|
||||
row.setAddressAreaNames(addresses.getOrDefault(user.getId(), Collections.emptyList()).stream()
|
||||
.map(MemberAddressDO::getAreaId)
|
||||
.filter(id -> id != null && id > 0 && id <= Integer.MAX_VALUE)
|
||||
.map(id -> AreaUtils.format(id.intValue(), "-"))
|
||||
.filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(";")));
|
||||
row.setMemberTags(tags.getOrDefault(user.getId(), Collections.emptyList()).stream()
|
||||
.map(tag -> tagLabels.getOrDefault(tag.getTagBizType(),
|
||||
StrUtil.blankToDefault(tag.getTagTitle(), tag.getTagBizType())))
|
||||
.filter(StrUtil::isNotBlank).distinct().collect(Collectors.joining(";")));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
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.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.MockMvc;
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class MemberUserExportControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MemberUserController controller;
|
||||
@Mock
|
||||
private MemberUserExportService service;
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void bindsIndexedDatesTagsAndAllSearchFieldsWithTheSameEncodingAsTheList() throws Exception {
|
||||
MemberUserExportRespVO result = new MemberUserExportRespVO();
|
||||
result.setTaskId("task-test");
|
||||
result.setStatus("WAITING");
|
||||
when(service.create(any())).thenReturn(result);
|
||||
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
mvc.perform(post("/member/user/export-async")
|
||||
.param("nickname", "会员").param("userCode", "000001")
|
||||
.param("mobile", "139").param("wechatNum", "wx_test")
|
||||
.param("addressAreaId", "330100").param("sex", "2").param("status", "0")
|
||||
.param("memberRecentTagList[0]", "4").param("memberRecentTagList[1]", "5")
|
||||
.param("createTime[0]", "2026-10-01 00:00:00").param("createTime[1]", "2026-10-30 23:59:59")
|
||||
.param("loginDate[0]", "2026-10-20 00:00:00").param("loginDate[1]", "2026-10-30 23:59:59")
|
||||
.param("levelId", "10").param("groupId", "20"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.taskId").value("task-test"));
|
||||
|
||||
ArgumentCaptor<MemberUserPageReqVO> captor = ArgumentCaptor.forClass(MemberUserPageReqVO.class);
|
||||
verify(service).create(captor.capture());
|
||||
MemberUserPageReqVO query = captor.getValue();
|
||||
assertThat(query.getNickname()).isEqualTo("会员");
|
||||
assertThat(query.getUserCode()).isEqualTo("000001");
|
||||
assertThat(query.getMobile()).isEqualTo("139");
|
||||
assertThat(query.getWechatNum()).isEqualTo("wx_test");
|
||||
assertThat(query.getAddressAreaId()).isEqualTo(330100);
|
||||
assertThat(query.getSex()).isEqualTo(2);
|
||||
assertThat(query.getStatus()).isZero();
|
||||
assertThat(query.getMemberRecentTagList()).containsExactly("4", "5");
|
||||
assertThat(query.getCreateTime()).containsExactly(LocalDateTime.of(2026, 10, 1, 0, 0), LocalDateTime.of(2026, 10, 30, 23, 59, 59));
|
||||
assertThat(query.getLoginDate()).containsExactly(LocalDateTime.of(2026, 10, 20, 0, 0), LocalDateTime.of(2026, 10, 30, 23, 59, 59));
|
||||
assertThat(query.getLevelId()).isEqualTo(10L);
|
||||
assertThat(query.getGroupId()).isEqualTo(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadStreamsTheFileWithExcelAndAttachmentHeaders() throws Exception {
|
||||
Path file = Files.write(directory.resolve("export.xlsx"), new byte[]{1, 2, 3});
|
||||
when(service.getDownloadFile("owned-task")).thenReturn(file);
|
||||
MockMvcBuilders.standaloneSetup(controller).build()
|
||||
.perform(get("/member/user/export-download").param("taskId", "owned-task"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(header().string("Content-Length", "3"))
|
||||
.andExpect(content().bytes(new byte[]{1, 2, 3}));
|
||||
}
|
||||
}
|
||||
+296
@@ -3,10 +3,16 @@ 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.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;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.address.MemberAddressDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressService;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
@@ -15,10 +21,14 @@ import org.springframework.web.bind.ServletRequestDataBinder;
|
||||
import javax.annotation.Resource;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(AddressServiceImpl.class)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:member_user_page_filters;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
|
||||
"spring.sql.init.schema-locations=classpath:/sql/create_tables.sql,classpath:/sql/member_user_page_filter_schema.sql"
|
||||
@@ -28,6 +38,8 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
@Resource
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Resource
|
||||
private AddressService addressService;
|
||||
@Resource
|
||||
private DataSource dataSource;
|
||||
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
@@ -44,6 +56,12 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
id, String.format("%06d", id), "会员" + id, sexes[index],
|
||||
id == 2 || id == 5 ? 1 : 0, "1390000000" + id, id == 4 ? "4" : null, id == 8);
|
||||
}
|
||||
setWechat(1L, "wx_alice_01", "https://example.com/wechat/1.png");
|
||||
setWechat(2L, "wx_alice_02", null);
|
||||
setWechat(3L, "wx_bob_03", "https://example.com/wechat/3.png");
|
||||
setWechat(4L, "", null);
|
||||
setWechat(6L, "wx_alice_06", null);
|
||||
setWechat(8L, "wx_alice_08", "https://example.com/wechat/8.png");
|
||||
insertTag(1L, "4", false);
|
||||
insertTag(1L, "4", false); // 历史重复标签不能重复计算用户。
|
||||
insertTag(1L, "1", false);
|
||||
@@ -52,6 +70,18 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
insertTag(5L, "4", true); // 已删除的标签不参与筛选。
|
||||
insertTag(6L, "5", false);
|
||||
insertTag(8L, "4", false); // 已删除的用户不参与筛选。
|
||||
insertAddress(1L, 330106L, true, false);
|
||||
insertAddress(1L, 330106L, false, false); // 同一地区只展示一次。
|
||||
insertAddress(1L, 110105L, false, false); // 非默认地址也参与展示和筛选。
|
||||
insertAddress(2L, 330102L, true, false);
|
||||
insertAddress(3L, 330203L, true, false);
|
||||
jdbcTemplate.update("UPDATE member_user SET area_id = 330106 WHERE id = 4"); // 会员资料地区不能冒充收货地址。
|
||||
insertAddress(5L, 330106L, true, true);
|
||||
insertAddress(6L, 330106L, false, true);
|
||||
insertAddress(6L, 330100L, true, false); // 历史仅保存到城市的地址。
|
||||
insertAddress(7L, 999999L, false, false); // 无法识别的历史地区不能显示错误名称。
|
||||
insertAddress(7L, 4295297402L, true, false); // Long 截断后恰好为 330106,不能误显示西湖区。
|
||||
insertAddress(8L, 330106L, true, false);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -72,6 +102,161 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
assertPage(memberUserMapper.selectPage(request), 4L, 5L, 4L, 3L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByWechatNumSupportsPartialMatches() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum("alice");
|
||||
assertPage(memberUserMapper.selectPage(request), 3L, 6L, 2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByWechatNumTrimsSurroundingWhitespace() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum(" wx_bob_03 \t");
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageWithBlankWechatNumDoesNotFilterUsers() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum(" \t ");
|
||||
assertPage(memberUserMapper.selectPage(request), 7L, 7L, 6L, 5L, 4L, 3L, 2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageCombinesWechatNumWithSexStatusAndTags() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum("alice");
|
||||
request.setSex(2);
|
||||
request.setStatus(1);
|
||||
request.setMemberRecentTagList(Arrays.asList("4", "5"));
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByUnknownWechatNumReturnsEmptyPage() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum("not_found");
|
||||
assertPage(memberUserMapper.selectPage(request), 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void managementPageResponseIncludesWechatNumAndQrCode() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setWechatNum("wx_alice_01");
|
||||
PageResult<MemberUserRespVO> response = MemberUserConvert.INSTANCE.convertPage(
|
||||
memberUserMapper.selectPage(request));
|
||||
|
||||
assertThat(response.getTotal()).isEqualTo(1L);
|
||||
assertThat(response.getList()).singleElement().satisfies(user -> {
|
||||
assertThat(user.getWechatNum()).isEqualTo("wx_alice_01");
|
||||
assertThat(user.getWechatQrCode()).isEqualTo("https://example.com/wechat/1.png");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void managementPageResponseAllowsMissingWechatContactFields() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setUserCode("000005");
|
||||
PageResult<MemberUserRespVO> response = MemberUserConvert.INSTANCE.convertPage(
|
||||
memberUserMapper.selectPage(request));
|
||||
|
||||
assertThat(response.getList()).singleElement().satisfies(user -> {
|
||||
assertThat(user.getWechatNum()).isNull();
|
||||
assertThat(user.getWechatQrCode()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByAddressProvinceIncludesAllDescendantCitiesAndDistricts() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(330000);
|
||||
assertPage(memberUserMapper.selectPage(request), 4L, 6L, 3L, 2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByAddressCityIncludesCityAndItsDistricts() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(330100);
|
||||
assertPage(memberUserMapper.selectPage(request), 3L, 6L, 2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByAddressDistrictIgnoresDeletedAddressesAndUserProfileArea() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(330106);
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByAddressIncludesNonDefaultAddresses() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(110100);
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageCombinesAddressWithSexStatusWechatAndTags() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(330000);
|
||||
request.setSex(2);
|
||||
request.setStatus(1);
|
||||
request.setWechatNum("alice");
|
||||
request.setMemberRecentTagList(Arrays.asList("4", "5"));
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByAddressDoesNotDuplicateUsersOrInflateTotal() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(330100);
|
||||
request.setPageSize(1);
|
||||
assertPage(memberUserMapper.selectPage(request), 3L, 6L);
|
||||
request.setPageNo(2);
|
||||
assertPage(memberUserMapper.selectPage(request), 3L, 2L);
|
||||
request.setPageNo(3);
|
||||
assertPage(memberUserMapper.selectPage(request), 3L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByUnrecognizedAddressAreaReturnsEmptyPage() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
request.setAddressAreaId(999999);
|
||||
assertPage(memberUserMapper.selectPage(request), 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void managementPageResponseFormatsAndDeduplicatesAllValidAddressAreas() {
|
||||
PageResult<MemberUserRespVO> response = MemberUserConvert.INSTANCE.convertPage(
|
||||
memberUserMapper.selectPage(new MemberUserPageReqVO()),
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
addressService.getAddressListByUserIds(Arrays.asList(1L, 2L, 3L, 4L, 5L, 6L, 7L)));
|
||||
|
||||
assertThat(response.getTotal()).isEqualTo(7L);
|
||||
assertThat(response.getList()).filteredOn(user -> user.getId().equals(1L))
|
||||
.singleElement().satisfies(user -> assertThat(user.getAddressAreaNames())
|
||||
.containsExactlyInAnyOrder("浙江省-杭州市-西湖区", "北京市-北京市-朝阳区"));
|
||||
assertThat(response.getList()).filteredOn(user -> user.getId().equals(3L))
|
||||
.singleElement().satisfies(user -> assertThat(user.getAddressAreaNames())
|
||||
.containsExactly("浙江省-宁波市-海曙区"));
|
||||
assertThat(response.getList()).filteredOn(user -> user.getId().equals(6L))
|
||||
.singleElement().satisfies(user -> assertThat(user.getAddressAreaNames())
|
||||
.containsExactly("浙江省-杭州市"));
|
||||
assertThat(response.getList()).filteredOn(user -> Arrays.asList(4L, 5L, 7L).contains(user.getId()))
|
||||
.hasSize(3).allSatisfy(user -> assertThat(user.getAddressAreaNames()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddressLookupOnlyReturnsSelectedUsersAndSkipsDeletedAddresses() {
|
||||
List<MemberAddressDO> addresses = addressService.getAddressListByUserIds(Arrays.asList(1L, 5L, 6L));
|
||||
|
||||
assertThat(addresses).hasSize(4);
|
||||
assertThat(addresses).extracting(MemberAddressDO::getUserId).containsOnly(1L, 6L);
|
||||
assertThat(addresses).allSatisfy(address -> assertThat(address.getDeleted()).isFalse());
|
||||
assertThat(addressService.getAddressListByUserIds(Collections.emptyList())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectPageByUnknownSexDoesNotTreatZeroAsEmpty() {
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
@@ -155,6 +340,8 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
|
||||
servletRequest.addParameter("sex", "2");
|
||||
servletRequest.addParameter("status", "1");
|
||||
servletRequest.addParameter("wechatNum", "alice");
|
||||
servletRequest.addParameter("addressAreaId", "330100");
|
||||
servletRequest.addParameter("memberRecentTagList[0]", "4");
|
||||
servletRequest.addParameter("memberRecentTagList[1]", "5");
|
||||
MemberUserPageReqVO request = new MemberUserPageReqVO();
|
||||
@@ -162,6 +349,8 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
binder.bind(servletRequest);
|
||||
|
||||
assertThat(binder.getBindingResult().hasErrors()).isFalse();
|
||||
assertThat(request.getWechatNum()).isEqualTo("alice");
|
||||
assertThat(request.getAddressAreaId()).isEqualTo(330100);
|
||||
assertThat(request.getMemberRecentTagList()).containsExactly("4", "5");
|
||||
assertPage(memberUserMapper.selectPage(request), 1L, 2L);
|
||||
}
|
||||
@@ -171,6 +360,113 @@ class MemberUserMapperTest extends BaseDbUnitTest {
|
||||
memberId, type, 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'");
|
||||
jdbcTemplate.update("UPDATE member_user SET level_id = 10, group_id = 20,"
|
||||
+ " create_time = '2026-10-20 10:00:00', login_date = '2026-10-21 10:00:00' WHERE id = 2");
|
||||
List<MemberUserPageReqVO> queries = new ArrayList<>();
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
query.setMobile("13900000002");
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setNickname("会员2");
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setUserCode("000002");
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setSex(2);
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setStatus(0);
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setLevelId(10L);
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setGroupId(20L);
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setWechatNum(" alice ");
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setAddressAreaId(330100);
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setMemberRecentTagList(Arrays.asList("1", "4"));
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
query.setCreateTime(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0), LocalDateTime.of(2026, 10, 20, 23, 59)});
|
||||
queries.add(query);
|
||||
query = new MemberUserPageReqVO();
|
||||
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.setSex(2);
|
||||
query.setStatus(1);
|
||||
query.setWechatNum("alice");
|
||||
query.setAddressAreaId(330000);
|
||||
query.setMemberRecentTagList(Arrays.asList("4", "5"));
|
||||
queries.add(query);
|
||||
|
||||
for (MemberUserPageReqVO filter : queries) {
|
||||
PageResult<MemberUserDO> page = memberUserMapper.selectPage(filter);
|
||||
MemberUserDO upper = memberUserMapper.selectExportUpperBound(filter);
|
||||
assertThat(upper).isNotNull();
|
||||
assertThat(memberUserMapper.selectExportCount(filter, upper.getId())).isEqualTo(page.getTotal());
|
||||
filter.setPageNo(50);
|
||||
filter.setPageSize(1);
|
||||
List<MemberUserDO> exported = new ArrayList<>();
|
||||
Long cursor = null;
|
||||
while (true) {
|
||||
List<MemberUserDO> batch = memberUserMapper.selectExportBatch(filter, upper.getId(), cursor, 2);
|
||||
if (batch.isEmpty()) break;
|
||||
exported.addAll(batch);
|
||||
cursor = batch.get(batch.size() - 1).getId();
|
||||
}
|
||||
assertThat(exported).extracting(MemberUserDO::getId)
|
||||
.containsExactlyElementsOf(page.getList().stream().map(MemberUserDO::getId).collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportCursorExcludesNewUsersAndDoesNotSkipAfterDeletion() {
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
Long maxId = memberUserMapper.selectExportUpperBound(query).getId();
|
||||
List<MemberUserDO> first = memberUserMapper.selectExportBatch(query, maxId, null, 2);
|
||||
assertThat(first).extracting(MemberUserDO::getId).containsExactly(7L, 6L);
|
||||
jdbcTemplate.update("INSERT INTO member_user (id, user_code, nickname, mobile, register_ip, status, deleted)"
|
||||
+ " VALUES (9, '000009', '新用户', '13900000009', '127.0.0.1', 0, FALSE)");
|
||||
jdbcTemplate.update("UPDATE member_user SET deleted = TRUE WHERE id = 4");
|
||||
assertThat(memberUserMapper.selectExportBatch(query, maxId, 6L, 10))
|
||||
.extracting(MemberUserDO::getId).containsExactly(5L, 3L, 2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportUnknownAddressAndTagDoNotFallBackToAllUsers() {
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
query.setAddressAreaId(999999);
|
||||
assertThat(memberUserMapper.selectExportUpperBound(query)).isNull();
|
||||
assertThat(memberUserMapper.selectExportCount(query, Long.MAX_VALUE)).isZero();
|
||||
assertThat(memberUserMapper.selectExportBatch(query, Long.MAX_VALUE, null, 2)).isEmpty();
|
||||
query.setAddressAreaId(null);
|
||||
query.setMemberRecentTagList(Collections.singletonList("4') OR 1=1 --"));
|
||||
assertThat(memberUserMapper.selectExportBatch(query, Long.MAX_VALUE, null, 2)).isEmpty();
|
||||
}
|
||||
|
||||
private void setWechat(Long memberId, String wechatNum, String wechatQrCode) {
|
||||
jdbcTemplate.update("UPDATE member_user SET wechat_num = ?, wechat_qr_code = ? WHERE id = ?",
|
||||
wechatNum, wechatQrCode, memberId);
|
||||
}
|
||||
|
||||
private void insertAddress(Long memberId, Long areaId, boolean defaultStatus, boolean deleted) {
|
||||
jdbcTemplate.update("INSERT INTO member_address"
|
||||
+ " (user_id, name, mobile, area_id, detail_address, default_status, deleted)"
|
||||
+ " VALUES (?, '收件人', '13900000000', ?, '测试路', ?, ?)",
|
||||
memberId, areaId, defaultStatus, deleted);
|
||||
}
|
||||
|
||||
private void assertPage(PageResult<MemberUserDO> result, long total, Long... ids) {
|
||||
assertThat(result.getTotal()).isEqualTo(total);
|
||||
assertThat(result.getList()).extracting(MemberUserDO::getId).containsExactly(ids);
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
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.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 org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MemberUserExportServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MemberUserExportService service;
|
||||
@Mock
|
||||
private MemberUserExportRedisDAO exportRedisDAO;
|
||||
@Mock
|
||||
private MemberUserExportWriter exportWriter;
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
private final Map<String, MemberUserExportTask> tasks = new HashMap<>();
|
||||
private final List<Runnable> queue = new ArrayList<>();
|
||||
private final List<Integer> percentages = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void configure() {
|
||||
login(100L, 1L);
|
||||
MemberUserExportProperties properties = new MemberUserExportProperties();
|
||||
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().doAnswer(invocation -> {
|
||||
MemberUserExportTask task = invocation.getArgument(0);
|
||||
task.setUpdatedAt(System.currentTimeMillis());
|
||||
tasks.put(key(task.getTenantId(), task.getUserId(), task.getTaskId()), BeanUtils.toBean(task, MemberUserExportTask.class));
|
||||
percentages.add(task.getProgress());
|
||||
return null;
|
||||
}).when(exportRedisDAO).save(any());
|
||||
lenient().when(exportRedisDAO.get(any(), any(), anyString())).thenAnswer(invocation ->
|
||||
tasks.get(key(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsAsynchronouslyUnderOriginalUserAndTenantAndOnlyFinishesAfterFileIsReady() throws Exception {
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
query.setSex(2);
|
||||
when(exportWriter.write(any(), eq(query), any())).thenAnswer(invocation -> {
|
||||
assertThat(TenantContextHolder.getTenantId()).isEqualTo(1L);
|
||||
LoginUser login = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
assertThat(login.getId()).isEqualTo(100L);
|
||||
BiConsumer<Long, Long> progress = invocation.getArgument(2);
|
||||
progress.accept(3L, 0L);
|
||||
progress.accept(3L, 3L);
|
||||
Files.write(invocation.getArgument(0), new byte[]{1, 2, 3});
|
||||
assertThat(percentages).doesNotContain(100);
|
||||
return 3L;
|
||||
});
|
||||
MemberUserExportRespVO submitted = service.create(query);
|
||||
assertThat(submitted.getStatus()).isEqualTo("WAITING");
|
||||
assertThat(queue).hasSize(1);
|
||||
assertThatThrownBy(() -> service.getDownloadFile(submitted.getTaskId())).isInstanceOf(ServiceException.class);
|
||||
|
||||
login(200L, 2L); // 模拟线程池中的其他上下文,执行后必须恢复。
|
||||
queue.remove(0).run();
|
||||
assertThat(TenantContextHolder.getTenantId()).isEqualTo(2L);
|
||||
assertThat(((LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getId()).isEqualTo(200L);
|
||||
login(100L, 1L);
|
||||
MemberUserExportRespVO finished = service.getProgress(submitted.getTaskId());
|
||||
assertThat(finished.getStatus()).isEqualTo("SUCCESS");
|
||||
assertThat(finished.getProgress()).isEqualTo(100);
|
||||
assertThat(finished.getProcessed()).isEqualTo(3);
|
||||
assertThat(finished.getTotal()).isEqualTo(3);
|
||||
assertThat(Files.readAllBytes(service.getDownloadFile(submitted.getTaskId()))).containsExactly(1, 2, 3);
|
||||
assertThat(directory.resolve(submitted.getTaskId() + ".part")).doesNotExist();
|
||||
verify(exportRedisDAO).release(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherUsersAndTenantsCannotReadProgressOrDownloadFile() {
|
||||
String taskId = service.create(new MemberUserPageReqVO()).getTaskId();
|
||||
login(200L, 1L);
|
||||
assertThatThrownBy(() -> service.getProgress(taskId)).isInstanceOf(ServiceException.class);
|
||||
assertThatThrownBy(() -> service.getDownloadFile(taskId)).isInstanceOf(ServiceException.class);
|
||||
login(100L, 2L);
|
||||
assertThatThrownBy(() -> service.getProgress(taskId)).isInstanceOf(ServiceException.class);
|
||||
assertThatThrownBy(() -> service.getDownloadFile("../../secret")).isInstanceOf(ServiceException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerFailureDeletesPartialFileAndProducesFailureProgress() throws Exception {
|
||||
when(exportWriter.write(any(), any(), any())).thenAnswer(invocation -> {
|
||||
Files.write(invocation.getArgument(0), new byte[]{1});
|
||||
throw new IllegalStateException("数据库内部错误和敏感参数");
|
||||
});
|
||||
String taskId = service.create(new MemberUserPageReqVO()).getTaskId();
|
||||
queue.remove(0).run();
|
||||
|
||||
MemberUserExportRespVO failed = service.getProgress(taskId);
|
||||
assertThat(failed.getStatus()).isEqualTo("FAILURE");
|
||||
assertThat(failed.getMessage()).doesNotContain("敏感参数");
|
||||
assertThat(directory.resolve(taskId + ".part")).doesNotExist();
|
||||
assertThatThrownBy(() -> service.getDownloadFile(taskId)).isInstanceOf(ServiceException.class);
|
||||
verify(exportRedisDAO).release(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWhenQueueIsFullAndReleasesUserLock() {
|
||||
ReflectionTestUtils.setField(service, "executor", (Executor) command -> { throw new RejectedExecutionException(); });
|
||||
assertThatThrownBy(() -> service.create(new MemberUserPageReqVO()))
|
||||
.isInstanceOf(ServiceException.class).hasMessageContaining("任务较多");
|
||||
assertThat(tasks.values()).singleElement().satisfies(task -> assertThat(task.getStatus()).isEqualTo("FAILURE"));
|
||||
verify(exportRedisDAO).release(any());
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
MemberUserExportRespVO duplicate = service.create(new MemberUserPageReqVO());
|
||||
assertThat(duplicate.getTaskId()).isEqualTo(taskId);
|
||||
assertThat(duplicate.isExistingTask()).isTrue();
|
||||
assertThat(queue).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptedTasksBecomeRetryableAfterWorkerHeartbeatExpires() {
|
||||
String taskId = service.create(new MemberUserPageReqVO()).getTaskId();
|
||||
MemberUserExportTask task = tasks.get(key(1L, 100L, taskId));
|
||||
task.setUpdatedAt(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(3));
|
||||
when(exportRedisDAO.isWorkerAlive(anyString())).thenReturn(false);
|
||||
|
||||
assertThat(service.getProgress(taskId).getStatus()).isEqualTo("FAILURE");
|
||||
verify(exportRedisDAO).release(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longRunningTaskRemainsActiveWhileWorkerIsAlive() {
|
||||
String taskId = service.create(new MemberUserPageReqVO()).getTaskId();
|
||||
tasks.get(key(1L, 100L, taskId)).setUpdatedAt(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1));
|
||||
when(exportRedisDAO.isWorkerAlive(anyString())).thenReturn(true);
|
||||
|
||||
assertThat(service.getProgress(taskId).getStatus()).isEqualTo("WAITING");
|
||||
verify(exportRedisDAO, never()).release(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupOnlyRemovesExpiredExportArtifacts() throws Exception {
|
||||
String id = UUID.randomUUID().toString();
|
||||
Path expired = Files.write(directory.resolve(id + ".xlsx"), new byte[]{1});
|
||||
Files.setLastModifiedTime(expired, java.nio.file.attribute.FileTime.fromMillis(
|
||||
System.currentTimeMillis() - TimeUnit.HOURS.toMillis(25)));
|
||||
Path recent = Files.write(directory.resolve(UUID.randomUUID() + ".xlsx"), new byte[]{1});
|
||||
Path other = Files.write(directory.resolve("unrelated.txt"), new byte[]{1});
|
||||
Path expiredImages = Files.createDirectory(directory.resolve("member-export-images-12345"));
|
||||
Files.write(expiredImages.resolve("avatar.png"), new byte[]{1});
|
||||
Files.setLastModifiedTime(expiredImages, java.nio.file.attribute.FileTime.fromMillis(
|
||||
System.currentTimeMillis() - TimeUnit.HOURS.toMillis(25)));
|
||||
service.cleanExpiredFiles();
|
||||
|
||||
assertThat(expired).doesNotExist();
|
||||
assertThat(recent).exists();
|
||||
assertThat(other).exists();
|
||||
assertThat(expiredImages).doesNotExist();
|
||||
}
|
||||
|
||||
private void login(Long userId, Long tenantId) {
|
||||
LoginUser user = new LoginUser();
|
||||
user.setId(userId);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()));
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
}
|
||||
|
||||
private String key(Long tenantId, Long userId, String taskId) {
|
||||
return tenantId + ":" + userId + ":" + taskId;
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
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;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberRecentTagDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.yudao.module.member.framework.export.config.MemberUserExportProperties;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressService;
|
||||
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
|
||||
import cn.iocoder.yudao.module.system.api.dict.dto.DictDataRespDTO;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.zip.ZipFile;
|
||||
import org.xml.sax.Attributes;
|
||||
import org.xml.sax.helpers.DefaultHandler;
|
||||
import javax.xml.parsers.SAXParserFactory;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MemberUserExportWriterTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MemberUserExportWriter writer;
|
||||
@Mock
|
||||
private MemberUserMapper memberUserMapper;
|
||||
@Mock
|
||||
private MemberRecentTagMapper memberRecentTagMapper;
|
||||
@Mock
|
||||
private AddressService addressService;
|
||||
@Mock
|
||||
private DictDataApi dictDataApi;
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@BeforeEach
|
||||
void configure() {
|
||||
MemberUserExportProperties properties = new MemberUserExportProperties();
|
||||
properties.setBatchSize(2);
|
||||
properties.setRowsPerSheet(3);
|
||||
ReflectionTestUtils.setField(writer, "properties", properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesBatchesAcrossSheetBoundariesAndPreservesTextAndDisplayValues() throws Exception {
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
MemberUserDO first = user(9007199254740993L).setUserCode("000001").setMobile("0013900000000")
|
||||
.setNickname("=1+1").setSex(2).setStatus(1).setWechatNum("wx_test")
|
||||
.setAvatar("http://127.0.0.1:1/ignored-avatar.png").setWechatQrCode("http://127.0.0.1:1/ignored-qr.png");
|
||||
when(memberUserMapper.selectExportUpperBound(query)).thenReturn(first);
|
||||
when(memberUserMapper.selectExportCount(query, first.getId())).thenReturn(5L);
|
||||
when(memberUserMapper.selectExportBatch(query, first.getId(), null, 2)).thenReturn(Arrays.asList(first, user(4L)));
|
||||
when(memberUserMapper.selectExportBatch(query, first.getId(), 4L, 2)).thenReturn(Arrays.asList(user(3L),
|
||||
user(2L).setAvatar("https://example.com/avatar.png").setWechatQrCode("https://example.com/missing.png")));
|
||||
when(memberUserMapper.selectExportBatch(query, first.getId(), 2L, 2)).thenReturn(Collections.singletonList(user(1L)));
|
||||
when(memberUserMapper.selectExportBatch(query, first.getId(), 1L, 2)).thenReturn(Collections.emptyList());
|
||||
DictDataRespDTO label = new DictDataRespDTO();
|
||||
label.setValue("4");
|
||||
label.setLabel("会员");
|
||||
when(dictDataApi.getDictDataList("member_recent_tag")).thenReturn(Collections.singletonList(label));
|
||||
when(addressService.getAddressListByUserIds(any())).thenAnswer(invocation -> {
|
||||
Collection<Long> ids = invocation.getArgument(0);
|
||||
return ids.contains(first.getId()) ? Arrays.asList(
|
||||
MemberAddressDO.builder().userId(first.getId()).areaId(330106L).build(),
|
||||
MemberAddressDO.builder().userId(first.getId()).areaId(330106L).build(),
|
||||
MemberAddressDO.builder().userId(first.getId()).areaId(4295297402L).build()) : Collections.emptyList();
|
||||
});
|
||||
when(memberRecentTagMapper.getByIds(any())).thenAnswer(invocation -> {
|
||||
Collection<Long> ids = invocation.getArgument(0);
|
||||
return ids.contains(first.getId()) ? Arrays.asList(
|
||||
MemberRecentTagDO.builder().memberId(first.getId()).tagBizType("4").tagTitle("旧标签").build(),
|
||||
MemberRecentTagDO.builder().memberId(first.getId()).tagBizType("4").build()) : Collections.emptyList();
|
||||
});
|
||||
List<Long> progress = new ArrayList<>();
|
||||
Path file = directory.resolve("users.xlsx");
|
||||
long exported = writer.write(file, query, (total, count) -> progress.add(count));
|
||||
|
||||
assertThat(exported).isEqualTo(5L);
|
||||
assertThat(progress).startsWith(0L).endsWith(5L).contains(2L, 4L).isSorted();
|
||||
try (Workbook workbook = WorkbookFactory.create(file.toFile())) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(2);
|
||||
assertThat(workbook.getSheetAt(0).getLastRowNum()).isEqualTo(3);
|
||||
assertThat(workbook.getSheetAt(1).getLastRowNum()).isEqualTo(2);
|
||||
Row row = workbook.getSheetAt(0).getRow(1);
|
||||
List<String> headers = new ArrayList<>();
|
||||
workbook.getSheetAt(0).getRow(0).forEach(cell -> headers.add(cell.getStringCellValue()));
|
||||
assertThat(headers).containsExactly("用户ID", "性别", "手机号", "昵称", "用户地址",
|
||||
"微信号", "用户标签", "是否禁用", "登录时间", "注册时间");
|
||||
assertThat(row.getCell(0).getStringCellValue()).isEqualTo("000001");
|
||||
assertThat(row.getCell(1).getStringCellValue()).isEqualTo("女");
|
||||
assertThat(row.getCell(2).getStringCellValue()).isEqualTo("0013900000000");
|
||||
assertThat(row.getCell(3).getCellType()).isEqualTo(CellType.STRING);
|
||||
assertThat(row.getCell(3).getStringCellValue()).isEqualTo("=1+1");
|
||||
assertThat(row.getCell(4).getStringCellValue()).isEqualTo("浙江省-杭州市-西湖区");
|
||||
assertThat(row.getCell(5).getStringCellValue()).isEqualTo("wx_test");
|
||||
assertThat(row.getCell(6).getStringCellValue()).isEqualTo("会员");
|
||||
assertThat(row.getCell(7).getStringCellValue()).isEqualTo("已禁用");
|
||||
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);
|
||||
assertThat(workbook.getAllPictures()).isEmpty();
|
||||
}
|
||||
verify(addressService, times(3)).getAddressListByUserIds(any());
|
||||
verify(memberRecentTagMapper, times(3)).getByIds(any());
|
||||
try (java.util.stream.Stream<Path> files = java.nio.file.Files.list(directory)) {
|
||||
assertThat(files.map(path -> path.getFileName().toString())).containsExactly("users.xlsx");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyResultsStillProduceAWorkbookWithHeaders() throws Exception {
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
when(memberUserMapper.selectExportUpperBound(query)).thenReturn(null);
|
||||
when(dictDataApi.getDictDataList("member_recent_tag")).thenReturn(Collections.emptyList());
|
||||
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("用户ID");
|
||||
}
|
||||
verify(memberUserMapper, never()).selectExportBatch(any(), anyLong(), any(), anyInt());
|
||||
verifyNoInteractions(addressService, memberRecentTagMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsOneHundredThousandRowsInFiveHundredRowBatches() throws Exception {
|
||||
MemberUserExportProperties properties = new MemberUserExportProperties();
|
||||
properties.setBatchSize(500);
|
||||
properties.setRowsPerSheet(40000);
|
||||
ReflectionTestUtils.setField(writer, "properties", properties);
|
||||
MemberUserPageReqVO query = new MemberUserPageReqVO();
|
||||
when(memberUserMapper.selectExportUpperBound(query)).thenReturn(user(100000L));
|
||||
when(memberUserMapper.selectExportCount(query, 100000L)).thenReturn(100000L);
|
||||
when(memberUserMapper.selectExportBatch(eq(query), eq(100000L), any(), eq(500))).thenAnswer(invocation -> {
|
||||
Long cursor = invocation.getArgument(2);
|
||||
long firstId = cursor == null ? 100000L : cursor - 1;
|
||||
List<MemberUserDO> users = new ArrayList<>();
|
||||
for (long id = firstId; id > 0 && users.size() < 500; id--) {
|
||||
users.add(user(id).setNickname("大量导出会员" + id).setMobile("13900000000")
|
||||
.setAvatar("https://example.com/avatar.png").setWechatNum("wx_user_" + id)
|
||||
.setWechatQrCode("https://example.com/qr.png"));
|
||||
}
|
||||
return users;
|
||||
});
|
||||
when(addressService.getAddressListByUserIds(any())).thenReturn(Collections.emptyList());
|
||||
when(memberRecentTagMapper.getByIds(any())).thenReturn(Collections.emptyList());
|
||||
when(dictDataApi.getDictDataList("member_recent_tag")).thenReturn(Collections.emptyList());
|
||||
Path file = directory.resolve("large.xlsx");
|
||||
AtomicLong lastProgress = new AtomicLong();
|
||||
assertThat(writer.write(file, query, (total, count) -> lastProgress.set(count))).isEqualTo(100000);
|
||||
assertThat(lastProgress.get()).isEqualTo(100000);
|
||||
verify(memberUserMapper, times(201)).selectExportBatch(eq(query), eq(100000L), any(), eq(500));
|
||||
|
||||
// SAX 校验真实 xlsx 内容,不把十万行重新装入内存。
|
||||
try (ZipFile zip = new ZipFile(file.toFile())) {
|
||||
for (int sheetIndex = 1; sheetIndex <= 3; sheetIndex++) {
|
||||
AtomicLong rows = new AtomicLong();
|
||||
SAXParserFactory.newInstance().newSAXParser().parse(
|
||||
zip.getInputStream(zip.getEntry("xl/worksheets/sheet" + sheetIndex + ".xml")),
|
||||
new DefaultHandler() {
|
||||
@Override
|
||||
public void startElement(String uri, String localName, String name, Attributes attributes) {
|
||||
if ("row".equals(name)) rows.incrementAndGet();
|
||||
}
|
||||
});
|
||||
assertThat(rows.get()).isEqualTo(sheetIndex < 3 ? 40001 : 20001);
|
||||
}
|
||||
assertThat(zip.getEntry("xl/worksheets/sheet4.xml")).isNull();
|
||||
assertThat(Collections.list(zip.entries())).noneMatch(entry ->
|
||||
entry.getName().startsWith("xl/media/") || entry.getName().startsWith("xl/drawings/"));
|
||||
}
|
||||
}
|
||||
|
||||
private MemberUserDO user(Long id) {
|
||||
return new MemberUserDO().setId(id).setUserCode("u" + id).setStatus(0).setSex(1)
|
||||
.setLoginDate(LocalDateTime.of(2026, 10, 30, 10, 20, 30));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -246,8 +246,8 @@ tx-map:
|
||||
app-secret: uYWt4DalxHgFnk6d9ul2KLdPVM1ABD9m
|
||||
|
||||
al-config:
|
||||
access-key-id: LTAI5tAK9M3fnMhmsxVZnuTn
|
||||
access-key-secret: 5J4EubEtDKHxvBytbzkgHTMQmzqpwd
|
||||
access-key-id: LTAI5t6ey9ia44LGqLnGdHuf
|
||||
access-key-secret: 1d8OFbypsc206M6G2bRkLtCudr28cL
|
||||
face-scene-id: 1000016556
|
||||
face-verify-callback-url: https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc
|
||||
face-verify-callback-token: uVuYOzoiQBWPp4A5kPgTddZlKWoSQlzc
|
||||
|
||||
@@ -256,8 +256,8 @@ tx-map:
|
||||
app-secret: uYWt4DalxHgFnk6d9ul2KLdPVM1ABD9m
|
||||
|
||||
al-config:
|
||||
access-key-id: LTAI5tAK9M3fnMhmsxVZnuTn
|
||||
access-key-secret: 5J4EubEtDKHxvBytbzkgHTMQmzqpwd
|
||||
access-key-id: LTAI5t6ey9ia44LGqLnGdHuf
|
||||
access-key-secret: 1d8OFbypsc206M6G2bRkLtCudr28cL
|
||||
face-scene-id: 1000016556
|
||||
face-verify-callback-url: https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc
|
||||
face-verify-callback-token: uVuYOzoiQBWPp4A5kPgTddZlKWoSQlzc
|
||||
|
||||
@@ -247,8 +247,8 @@ tx-map:
|
||||
app-secret: uYWt4DalxHgFnk6d9ul2KLdPVM1ABD9m
|
||||
|
||||
al-config:
|
||||
access-key-id: LTAI5tBrkf3ynA5CMLb4TYbo
|
||||
access-key-secret: 5J4EubEtDKHxvBytbzkgHTMQmzqpwd
|
||||
access-key-id: LTAI5t6ey9ia44LGqLnGdHuf
|
||||
access-key-secret: 1d8OFbypsc206M6G2bRkLtCudr28cL
|
||||
face-scene-id: 1000016556
|
||||
face-verify-callback-url: https://api.htysapp.com
|
||||
face-verify-callback-token: uVuYOzoiQBWPp4A5kPgTddZlKWoSQlzc
|
||||
|
||||
Reference in new issue
Block a user