diff --git a/docs/member-user-export.md b/docs/member-user-export.md new file mode 100644 index 0000000..bb127a2 --- /dev/null +++ b/docs/member-user-export.md @@ -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 +``` diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java index 75ccf12..24d58e4 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java @@ -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 memberRecentTags = memberRecentTagMapper.getByIds(convertSet(pageResult.getList(), MemberUserDO::getId)); // 查询用户配置 List memberCfgList = memberRecentCfgService.getByMemberIds(convertSet(pageResult.getList(), MemberUserDO::getId)); - return success(MemberUserConvert.INSTANCE.convertPage(pageResult, tags, memberRecentTags, levels, groups, memberCfgList)); + // 按本页用户批量查询收货地址,避免逐个用户查询。 + List 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 exportUsers(@Valid MemberUserPageReqVO query) { + return success(memberUserExportService.create(query)); + } + + @GetMapping("/export-progress") + @Operation(summary = "查询当前登录人提交的会员导出进度") + @PreAuthorize("@ss.hasPermission('member:user:query')") + public CommonResult getExportProgress(@RequestParam("taskId") String taskId) { + return success(memberUserExportService.getProgress(taskId)); + } + + @GetMapping("/export-download") + @Operation(summary = "下载当前登录人的会员导出文件") + @PreAuthorize("@ss.hasPermission('member:user:query')") + public ResponseEntity 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)); } } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRespVO.java new file mode 100644 index 0000000..ed18b91 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRespVO.java @@ -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; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java new file mode 100644 index 0000000..f90cc7f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserExportRowVO.java @@ -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; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java index 596435b..b3a90d1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java @@ -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; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java index 163bbaa..c3b4d09 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java @@ -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 addressAreaNames; + @Schema(description = "注册 IP", requiredMode = Schema.RequiredMode.REQUIRED, example = "127.0.0.1") private String registerIp; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java index 098f76d..abd220c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java @@ -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 memberRecentTags, List levels, List groups, - List memberCfgList + List memberCfgList, + List addresses ) { PageResult result = convertPage(pageResult); // 处理关联数据 @@ -64,12 +69,21 @@ public interface MemberUserConvert { Map levelMap = convertMap(levels, MemberLevelDO::getId, MemberLevelDO::getName); Map groupMap = convertMap(groups, MemberGroupDO::getId, MemberGroupDO::getName); Map memberCfgMap = convertMap(memberCfgList, MemberRecentCfgDO::getMemberId); + Map> 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)) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java index 9952b3b..48e1db7 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java @@ -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 { } default PageResult selectPage(MemberUserPageReqVO reqVO) { + return selectPage(reqVO, buildUserQuery(reqVO).orderByDesc(MemberUserDO::getId)); + } + + /** 列表、导出计数和游标批次共用筛选条件,避免新增搜索项时遗漏导出。 */ + default LambdaQueryWrapper buildUserQuery(MemberUserPageReqVO reqVO) { // 处理 tagIds 过滤条件 String tagIdSql = ""; if (CollUtil.isNotEmpty(reqVO.getTagIds())) { @@ -86,9 +94,9 @@ public interface MemberUserMapper extends BaseMapperX { .map(tagId -> "FIND_IN_SET(" + tagId + ", tag_ids)") .collect(Collectors.joining(" OR ", "(", ")")); } - // 分页查询 LambdaQueryWrapper query = new LambdaQueryWrapperX() .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 { .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 { + " 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 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 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) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java new file mode 100644 index 0000000..f66e58b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/redis/user/MemberUserExportRedisDAO.java @@ -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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportConfiguration.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportConfiguration.java new file mode 100644 index 0000000..7ff86df --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportConfiguration.java @@ -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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportProperties.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportProperties.java new file mode 100644 index 0000000..7227496 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/export/config/MemberUserExportProperties.java @@ -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; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressService.java index 099c49c..3a09a37 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressService.java @@ -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 getAddressList(Long userId); + /** + * 批量获得用户收件地址,用于会员列表展示。 + * + * @param userIds 用户编号集合 + * @return 未删除的用户收件地址列表 + */ + List getAddressListByUserIds(Collection userIds); + /** * 获得用户默认的收件地址 * diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressServiceImpl.java index 6a6243b..2226d2c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/address/AddressServiceImpl.java @@ -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 getAddressListByUserIds(Collection userIds) { + return memberAddressMapper.selectList(MemberAddressDO::getUserId, userIds); + } + @Override public MemberAddressDO getDefaultUserAddress(Long userId) { List addresses = memberAddressMapper.selectListByUserIdAndDefaulted(userId, true); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java new file mode 100644 index 0000000..f5873ca --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/BO/MemberUserExportTask.java @@ -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; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java new file mode 100644 index 0000000..3980c27 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportService.java @@ -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 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 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() { + @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; + } + }); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java new file mode 100644 index 0000000..c883aa4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriter.java @@ -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 progress) { + MemberUserDO upperBound = memberUserMapper.selectExportUpperBound(query); + long total = upperBound == null ? 0 : memberUserMapper.selectExportCount(query, upperBound.getId()); + progress.accept(total, 0L); + Map 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 users = memberUserMapper.selectExportBatch(query, upperBound.getId(), lastId, + properties.getBatchSize()); + if (users.isEmpty()) { + break; + } + List 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 buildRows(List users, Map tagLabels) { + Set userIds = convertSet(users, MemberUserDO::getId); + Map> addresses = addressService.getAddressListByUserIds(userIds).stream() + .collect(Collectors.groupingBy(MemberAddressDO::getUserId)); + Map> tags = memberRecentTagMapper.getByIds(userIds).stream() + .collect(Collectors.groupingBy(MemberRecentTagDO::getMemberId)); + List 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserExportControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserExportControllerTest.java new file mode 100644 index 0000000..cfbfbac --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserExportControllerTest.java @@ -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 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})); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java index 509b105..212f858 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapperTest.java @@ -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 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 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 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 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 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 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 exported = new ArrayList<>(); + Long cursor = null; + while (true) { + List 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 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 result, long total, Long... ids) { assertThat(result.getTotal()).isEqualTo(total); assertThat(result.getList()).extracting(MemberUserDO::getId).containsExactly(ids); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java new file mode 100644 index 0000000..3b8bb0f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportServiceTest.java @@ -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 tasks = new HashMap<>(); + private final List queue = new ArrayList<>(); + private final List 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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java new file mode 100644 index 0000000..5f4d697 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserExportWriterTest.java @@ -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 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 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 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 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 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 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)); + } + +} diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index 88c2785..d2bab76 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -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 diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index 4477e61..4b9f835 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -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 diff --git a/yudao-server/src/main/resources/application-prod.yaml b/yudao-server/src/main/resources/application-prod.yaml index cf48740..d68022d 100644 --- a/yudao-server/src/main/resources/application-prod.yaml +++ b/yudao-server/src/main/resources/application-prod.yaml @@ -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