feat:会员管理优化增加导出

This commit is contained in:
dzc committed 2026-10-30 20:02:59 +08:00
1 parent e04e1ca08b
commit e4b079279b
23 files changed
+1576 -12

No files matched your search

+42
View File
@@ -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
```
@@ -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));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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;
@@ -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)) {
@@ -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) {
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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);
/**
* 获得用户默认的收件地址
*
@@ -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);
@@ -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;
}
@@ -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;
}
});
}
}
@@ -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;
}
}
@@ -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}));
}
}
@@ -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);
@@ -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;
}
}
@@ -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