新增会员管理
This commit is contained in:
+11
-9
@@ -155,8 +155,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
||||||
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", detail.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
|
||||||
detail.setPhone(null);
|
detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ServerResponse.ok()
|
return ServerResponse.ok()
|
||||||
@@ -204,8 +204,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||||
member.setPhone(null);
|
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
@@ -222,11 +222,13 @@ public class MemberHandler {
|
|||||||
|
|
||||||
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
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);
|
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
|
||||||
// TODO: 补充签到记录
|
adminId, pageNum, pageSize, sortField, sortOrder);
|
||||||
|
|
||||||
return memberService.findAll(pageNum, pageSize)
|
return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
|
||||||
.map(member -> {
|
.map(member -> {
|
||||||
// 解密手机号
|
// 解密手机号
|
||||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||||
@@ -234,8 +236,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||||
member.setPhone(null);
|
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ public interface MemberService {
|
|||||||
* @param pageSize 页大小
|
* @param pageSize 页大小
|
||||||
* @return 所有会员信息
|
* @return 所有会员信息
|
||||||
*/
|
*/
|
||||||
Flux<Member> findAll(Integer pageNum, Integer pageSize);
|
Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前台管理端获取会员详情(含会员卡信息)
|
* 前台管理端获取会员详情(含会员卡信息)
|
||||||
|
|||||||
+62
-42
@@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@@ -230,13 +231,31 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */
|
||||||
|
private static final java.util.Map<String, String> ALLOWED_SORT_FIELDS = java.util.Map.of(
|
||||||
|
"createdAt", "created_at",
|
||||||
|
"memberNo", "member_no",
|
||||||
|
"gender", "gender",
|
||||||
|
"id", "id"
|
||||||
|
);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<Member> findAll(Integer pageNum, Integer pageSize) {
|
public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
|
||||||
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize);
|
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(
|
Pageable pageable = PageRequest.of(
|
||||||
pageNum - 1,
|
pageNum - 1,
|
||||||
pageSize
|
pageSize,
|
||||||
|
Sort.by(direction, dbField)
|
||||||
);
|
);
|
||||||
|
|
||||||
return memberRepository.findAllBy(pageable);
|
return memberRepository.findAllBy(pageable);
|
||||||
@@ -249,50 +268,51 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
||||||
|
|
||||||
return redisUtil.get(cacheKey, MemberDetailVO.class)
|
return redisUtil.get(cacheKey, MemberDetailVO.class)
|
||||||
.flatMap(cached -> {
|
.filter(cached -> cached != null)
|
||||||
if (cached != null) {
|
.switchIfEmpty(Mono.defer(() ->
|
||||||
log.debug("从缓存获取会员详情, memberId: {}", memberId);
|
memberRepository.findById(memberId)
|
||||||
return Mono.just(cached);
|
.switchIfEmpty(Mono.error(() -> {
|
||||||
}
|
log.error("会员不存在: memberId={}", memberId);
|
||||||
return memberRepository.findById(memberId)
|
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||||
.zipWith(
|
}))
|
||||||
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
.zipWith(
|
||||||
.collectList(),
|
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
||||||
(baseInfo, cardList) -> {
|
.collectList(),
|
||||||
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
(baseInfo, cardList) -> {
|
||||||
|
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
||||||
|
|
||||||
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
||||||
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
||||||
|
|
||||||
List<MemberCardInfoVO> enrichedCards = cardList.stream()
|
List<MemberCardInfoVO> enrichedCards = cardList.stream()
|
||||||
.peek(vo -> {
|
.peek(vo -> {
|
||||||
if (vo.getMemberCardType() != null) {
|
if (vo.getMemberCardType() != null) {
|
||||||
try {
|
try {
|
||||||
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
|
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
|
||||||
vo.setMemberCardTypeDesc(cardType.getDesc());
|
vo.setMemberCardTypeDesc(cardType.getDesc());
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
vo.setMemberCardTypeDesc(vo.getMemberCardType());
|
vo.setMemberCardTypeDesc(vo.getMemberCardType());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (vo.getMemberCardStatus() != null) {
|
||||||
if (vo.getMemberCardStatus() != null) {
|
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
|
||||||
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
|
}
|
||||||
}
|
})
|
||||||
})
|
.collect(Collectors.toList());
|
||||||
.collect(Collectors.toList());
|
memberDetailVO.setMemberCards(enrichedCards);
|
||||||
memberDetailVO.setMemberCards(enrichedCards);
|
|
||||||
|
|
||||||
long activeCount = enrichedCards.stream()
|
long activeCount = enrichedCards.stream()
|
||||||
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
|
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
|
||||||
.count();
|
.count();
|
||||||
memberDetailVO.setActiveCardCount((int) activeCount);
|
memberDetailVO.setActiveCardCount((int) activeCount);
|
||||||
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
|
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
|
||||||
|
|
||||||
return memberDetailVO;
|
return memberDetailVO;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||||
.then(Mono.just(vo)));
|
.then(Mono.just(vo)))
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -1,5 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.common.config;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
|
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.RedisSerializationContext;
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 配置类(响应式版本)
|
* Redis 配置类(响应式版本)
|
||||||
*
|
*
|
||||||
@@ -17,6 +24,24 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class RedisConfig {
|
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
|
* 配置 ReactiveRedisTemplate
|
||||||
*/
|
*/
|
||||||
@@ -24,13 +49,16 @@ public class RedisConfig {
|
|||||||
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
|
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
|
||||||
ReactiveRedisConnectionFactory connectionFactory) {
|
ReactiveRedisConnectionFactory connectionFactory) {
|
||||||
|
|
||||||
|
GenericJackson2JsonRedisSerializer jsonSerializer =
|
||||||
|
new GenericJackson2JsonRedisSerializer(createRedisObjectMapper());
|
||||||
|
|
||||||
// 配置序列化上下文
|
// 配置序列化上下文
|
||||||
RedisSerializationContext<String, Object> serializationContext =
|
RedisSerializationContext<String, Object> serializationContext =
|
||||||
RedisSerializationContext.<String, Object>newSerializationContext()
|
RedisSerializationContext.<String, Object>newSerializationContext()
|
||||||
.key(StringRedisSerializer.UTF_8)
|
.key(StringRedisSerializer.UTF_8)
|
||||||
.value(new GenericJackson2JsonRedisSerializer())
|
.value(jsonSerializer)
|
||||||
.hashKey(StringRedisSerializer.UTF_8)
|
.hashKey(StringRedisSerializer.UTF_8)
|
||||||
.hashValue(new GenericJackson2JsonRedisSerializer())
|
.hashValue(jsonSerializer)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
|
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.common.util;
|
package cn.novalon.gym.manage.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -13,6 +14,7 @@ import java.time.Duration;
|
|||||||
* @author liwentao
|
* @author liwentao
|
||||||
* @date 2026/5/15
|
* @date 2026/5/15
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class RedisUtil {
|
public class RedisUtil {
|
||||||
|
|
||||||
@@ -34,12 +36,19 @@ public class RedisUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取值
|
* 获取值(带类型转换)
|
||||||
|
* filter+cast 替代 map 避免 NPE,onErrorResume 兼容旧缓存格式反序列化失败
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> Mono<T> get(String key, Class<T> clazz) {
|
public <T> Mono<T> get(String key, Class<T> clazz) {
|
||||||
return reactiveRedisTemplate.opsForValue().get(key)
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+23
@@ -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));
|
||||||
Reference in New Issue
Block a user