diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java index 977ca11..8e435ed 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java @@ -155,8 +155,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(detail.getPhone()); detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", detail.getId(), e); - detail.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId()); + detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone())); } } return ServerResponse.ok() @@ -204,8 +204,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(member.getPhone()); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", member.getId(), e); - member.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); + member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); } } return member; @@ -222,11 +222,13 @@ public class MemberHandler { int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); + String sortField = request.queryParam("sortField").orElse(null); + String sortOrder = request.queryParam("sortOrder").orElse(null); - log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize); - // TODO: 补充签到记录 + log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}", + adminId, pageNum, pageSize, sortField, sortOrder); - return memberService.findAll(pageNum, pageSize) + return memberService.findAll(pageNum, pageSize, sortField, sortOrder) .map(member -> { // 解密手机号 if (member.getPhone() != null && !member.getPhone().isEmpty()) { @@ -234,8 +236,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(member.getPhone()); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", member.getId(), e); - member.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); + member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); } } return member; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java index 72e4b1f..da3127a 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java @@ -58,7 +58,7 @@ public interface MemberService { * @param pageSize 页大小 * @return 所有会员信息 */ - Flux findAll(Integer pageNum, Integer pageSize); + Flux findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder); /** * 前台管理端获取会员详情(含会员卡信息) diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java index ccee56c..8567be2 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java @@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @@ -230,13 +231,31 @@ public class MemberServiceImpl implements MemberService { ); } + /** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */ + private static final java.util.Map ALLOWED_SORT_FIELDS = java.util.Map.of( + "createdAt", "created_at", + "memberNo", "member_no", + "gender", "gender", + "id", "id" + ); + @Override - public Flux findAll(Integer pageNum, Integer pageSize) { - log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); + public Flux findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) { + log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}", + pageNum, pageSize, sortField, sortOrder); + + // 排序方向:desc(默认) / asc + Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder) + ? Sort.Direction.ASC : Sort.Direction.DESC; + + // 排序字段白名单校验,不在白名单中则使用默认排序 + String dbField = (sortField != null && ALLOWED_SORT_FIELDS.containsKey(sortField)) + ? ALLOWED_SORT_FIELDS.get(sortField) : "created_at"; Pageable pageable = PageRequest.of( pageNum - 1, - pageSize + pageSize, + Sort.by(direction, dbField) ); return memberRepository.findAllBy(pageable); @@ -249,50 +268,51 @@ public class MemberServiceImpl implements MemberService { String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; return redisUtil.get(cacheKey, MemberDetailVO.class) - .flatMap(cached -> { - if (cached != null) { - log.debug("从缓存获取会员详情, memberId: {}", memberId); - return Mono.just(cached); - } - return memberRepository.findById(memberId) - .zipWith( - memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) - .collectList(), - (baseInfo, cardList) -> { - MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); + .filter(cached -> cached != null) + .switchIfEmpty(Mono.defer(() -> + memberRepository.findById(memberId) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在: memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })) + .zipWith( + memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) + .collectList(), + (baseInfo, cardList) -> { + MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); - GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); - memberDetailVO.setGenderDesc(genderEnum.getDesc()); + GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); + memberDetailVO.setGenderDesc(genderEnum.getDesc()); - List enrichedCards = cardList.stream() - .peek(vo -> { - if (vo.getMemberCardType() != null) { - try { - MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); - vo.setMemberCardTypeDesc(cardType.getDesc()); - } catch (IllegalArgumentException e) { - vo.setMemberCardTypeDesc(vo.getMemberCardType()); + List enrichedCards = cardList.stream() + .peek(vo -> { + if (vo.getMemberCardType() != null) { + try { + MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); + vo.setMemberCardTypeDesc(cardType.getDesc()); + } catch (IllegalArgumentException e) { + vo.setMemberCardTypeDesc(vo.getMemberCardType()); + } } - } - if (vo.getMemberCardStatus() != null) { - vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); - } - }) - .collect(Collectors.toList()); - memberDetailVO.setMemberCards(enrichedCards); + if (vo.getMemberCardStatus() != null) { + vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); + } + }) + .collect(Collectors.toList()); + memberDetailVO.setMemberCards(enrichedCards); - long activeCount = enrichedCards.stream() - .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) - .count(); - memberDetailVO.setActiveCardCount((int) activeCount); - memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); + long activeCount = enrichedCards.stream() + .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) + .count(); + memberDetailVO.setActiveCardCount((int) activeCount); + memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); - return memberDetailVO; - } - ) - .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) - .then(Mono.just(vo))); - }); + return memberDetailVO; + } + ) + .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) + .then(Mono.just(vo))) + )); } diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java index 6b9f23c..483e1a4 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java @@ -1,5 +1,9 @@ package cn.novalon.gym.manage.common.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; @@ -8,6 +12,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + /** * Redis 配置类(响应式版本) * @@ -17,20 +24,41 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { + /** + * 创建支持 Java 8 时间类型与类型信息的 ObjectMapper,用于 Redis JSON 序列化 + */ + private ObjectMapper createRedisObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + JavaTimeModule javaTimeModule = new JavaTimeModule(); + javaTimeModule.addSerializer(LocalDateTime.class, + new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + mapper.registerModule(javaTimeModule); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + // 启用默认类型信息,确保反序列化时能还原为原始 Java 类型 + mapper.activateDefaultTyping( + mapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + return mapper; + } + /** * 配置 ReactiveRedisTemplate */ @Bean public ReactiveRedisTemplate reactiveRedisTemplate( ReactiveRedisConnectionFactory connectionFactory) { - + + GenericJackson2JsonRedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(createRedisObjectMapper()); + // 配置序列化上下文 RedisSerializationContext serializationContext = RedisSerializationContext.newSerializationContext() .key(StringRedisSerializer.UTF_8) - .value(new GenericJackson2JsonRedisSerializer()) + .value(jsonSerializer) .hashKey(StringRedisSerializer.UTF_8) - .hashValue(new GenericJackson2JsonRedisSerializer()) + .hashValue(jsonSerializer) .build(); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java index e880dee..32245a5 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java @@ -1,5 +1,6 @@ package cn.novalon.gym.manage.common.util; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.stereotype.Component; @@ -13,6 +14,7 @@ import java.time.Duration; * @author liwentao * @date 2026/5/15 */ +@Slf4j @Component public class RedisUtil { @@ -34,12 +36,19 @@ public class RedisUtil { } /** - * 获取值 + * 获取值(带类型转换) + * filter+cast 替代 map 避免 NPE,onErrorResume 兼容旧缓存格式反序列化失败 */ @SuppressWarnings("unchecked") public Mono get(String key, Class clazz) { return reactiveRedisTemplate.opsForValue().get(key) - .map(obj -> clazz.isInstance(obj) ? (T) obj : null); + .filter(clazz::isInstance) + .cast(clazz) + .onErrorResume(e -> { + // 旧缓存数据格式不兼容时静默跳过,后续逻辑会 fallback 到 DB 查询 + log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage()); + return Mono.empty(); + }); } /** diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Add_Member_Management_Menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Add_Member_Management_Menu.sql new file mode 100644 index 0000000..0e2c961 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Add_Member_Management_Menu.sql @@ -0,0 +1,23 @@ +-- ============================================ +-- V29: 新增会员管理菜单 +-- 描述: 为会员管理功能创建菜单项 +-- ============================================ + +-- 会员管理一级菜单(目录) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(500, '会员管理', 0, 5, 'M', NULL, NULL, 1, NOW(), NOW()); + +-- 会员管理子菜单(页面) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(501, '会员管理', 500, 1, 'C', 'business:member:list', 'member/member/index', 1, NOW(), NOW()); + +-- 会员管理按钮权限 +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(5011, '会员查询', 501, 1, 'F', 'business:member:query', NULL, 1, NOW(), NOW()), +(5012, '会员新增', 501, 2, 'F', 'business:member:add', NULL, 1, NOW(), NOW()), +(5013, '会员修改', 501, 3, 'F', 'business:member:edit', NULL, 1, NOW(), NOW()), +(5014, '会员删除', 501, 4, 'F', 'business:member:remove', NULL, 1, NOW(), NOW()), +(5015, '会员详情', 501, 5, 'F', 'business:member:detail', NULL, 1, NOW(), NOW()); + +-- 重置菜单序列 +SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));