更新前台相关功能,添加防XSS注入,加入ES搜索

This commit is contained in:
future
2026-05-29 14:25:17 +08:00
parent 7c08c685d0
commit 29b73c1f67
25 changed files with 635 additions and 133 deletions
@@ -12,8 +12,8 @@ public class SearchMemberDto {
// 搜索字段 - 包括 会员号、昵称、手机号
private String searchValue;
// 排序
private String filter;
// 性别排序
private Integer gender;
// 页码
private Integer pageNum = 1;
@@ -1,30 +1,32 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
import java.util.Date;
/**
* 更新会员信息Dto
*
*
* @author 付嘉
* @date 2026-05-10
*/
@Data
public class UpdateMemberInfoDto {
// 昵称
private String nickname;
// 性别
private Integer gender;
private GenderEnum gender;
// 生日
private Date birthday;
private LocalDate birthday;
// 头像
private String avatar;
// 地址
private String address;
}
}
@@ -33,6 +33,9 @@ public abstract class BaseEntity implements Persistable<Long> {
@Column("updated_at")
private LocalDateTime updatedAt;
@Column("deleted_at")
private LocalDateTime deletedAt;
// 判断当前实体是否是新建的
@Override
public boolean isNew() {
@@ -1,13 +1,10 @@
package cn.novalon.gym.manage.member.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.*;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
@@ -44,7 +41,7 @@ public class Member extends BaseEntity {
//生日
@Column("birthday")
private Date birthday;
private LocalDate birthday;
//地址
@Column("address")
@@ -1,38 +0,0 @@
package cn.novalon.gym.manage.member.entity;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("sign_in_record")
public class SignInRecord {
@Id
private Long id;
// 会员ID
@Column("member_id")
private Long memberId;
// 签到日期
@Column("sign_in_date")
private LocalDate signInDate;
// 签到时间
@Column("sign_in_time")
private LocalDateTime signInTime;
// 创建时间
@CreatedDate
@Column("created_at")
private LocalDateTime createdAt;
}
@@ -0,0 +1,37 @@
package cn.novalon.gym.manage.member.enums;
import lombok.Getter;
/**
* 性别枚举
*
* @author 付嘉
* @date 2026-05-29
*/
@Getter
public enum GenderEnum {
UNKNOWN(0, "未知"),
MALE(1, ""),
FEMALE(2, "");
private final Integer code;
private final String desc;
GenderEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public static GenderEnum fromCode(Integer code) {
if (code == null) {
return UNKNOWN;
}
for (GenderEnum gender : values()) {
if (gender.code.equals(code)) {
return gender;
}
}
return UNKNOWN;
}
}
@@ -1,12 +1,18 @@
package cn.novalon.gym.manage.member.es.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "gym_members")
public class MemberES {
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.member.es.repository;
import cn.novalon.gym.manage.member.es.entity.MemberES;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@@ -15,6 +16,6 @@ public interface MemberESRepository extends ReactiveElasticsearchRepository<Memb
/**
* 前台通用搜索:会员号(精确匹配) 或 昵称(模糊匹配) 或 手机号(精确匹配)并且 性别筛选(精确匹配)
*/
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContainingAndGender(
String memberNo, String phone, String nickname,String gender, Pageable pageable);
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContaining(
String memberNo, String phone, String nickname, Pageable pageable);
}
@@ -8,6 +8,7 @@ import cn.novalon.gym.manage.member.service.MemberService;
import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@@ -34,8 +35,6 @@ public class MemberHandler {
private final MemberService memberService;
private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService;
private final JwtTokenProvider jwtTokenProvider;
private final WechatProperties wechatProperties;
private final AuthUtil authUtil;
/**
@@ -179,11 +178,21 @@ public class MemberHandler {
log.info("前台查看会员信息, adminId: {}, memberId: {}", adminId, memberId);
// TODO 多表查询:会员信息、团课信息、会员卡信息
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue("成功");
return memberService.getMemberDetail(memberId)
.flatMap(detail -> {
if (detail.getPhone() != null && !detail.getPhone().isEmpty()) {
try {
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) {
log.error("手机号解密失败, memberId: {}", detail.getId(), e);
detail.setPhone(null);
}
}
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(detail);
});
}
/**
@@ -201,13 +210,14 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
// TODO 多表查询:会员信息、团课信息、会员卡信息
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue("成功");
return request.bodyToMono(UpdateMemberInfoDto.class)
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
.flatMap(detail -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(detail));
}
/**
@@ -221,9 +231,9 @@ public class MemberHandler {
Long adminId = authUtil.getMemberIdOrThrow(request);
String keyword = request.queryParam("searchValue").orElse(null);
String filter = request.queryParam("filter").orElse(null);
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
Integer filter = NumberUtils.toInt(request.queryParam("filter").orElse("-1"), -1);
Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
Integer pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
log.info("前台搜索会员列表, adminId: {}, keyword: {}, filter: {}, pageNum: {}, pageSize: {}",
adminId, keyword, filter, pageNum, pageSize);
@@ -233,10 +243,8 @@ public class MemberHandler {
// 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
try {
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
member.setPhone(decryptedPhone);
String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e);
member.setPhone(null);
@@ -263,16 +271,15 @@ public class MemberHandler {
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
// TODO: 补充签到记录
return memberService.findAll(pageNum, pageSize)
.map(member -> {
// 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
try {
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
member.setPhone(decryptedPhone);
String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e);
member.setPhone(null);
@@ -1,7 +1,9 @@
package cn.novalon.gym.manage.member.repository;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
import org.springframework.data.domain.Pageable;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@@ -30,7 +32,44 @@ public interface IMemberRepository extends R2dbcRepository<Member, Long> {
/**
* 分页查询所有会员
* 方法名 findAllBy 是 Spring Data 的约定,表示按条件查询所有
*/
Flux<Member> findAllBy(Pageable pageable);
/**
* 查询会员的所有卡片
*/
@Query("SELECT " +
" r.id, " +
" r.member_card_record_id, " +
" r.member_id, " +
" r.member_card_id, " +
" r.status, " +
" r.remaining_times, " +
" r.remaining_amount, " +
" r.expire_time, " +
" r.purchase_time, " +
" r.source_order_id, " +
" r.created_at, " +
" r.updated_at, " +
" r.version, " +
" r.card_composition, " +
" c.id AS card_id, " +
" c.member_card_id, " +
" c.member_card_name, " +
" c.member_card_type, " +
" c.member_card_price, " +
" c.member_card_validity_days, " +
" c.member_card_total_times, " +
" c.member_card_amount, " +
" c.member_card_status, " +
" c.extra_config, " +
" c.created_at AS card_created_at, " +
" c.updated_at AS card_updated_at " +
"FROM member_card_record r " +
"LEFT JOIN member_card c ON r.member_card_id = c.id " +
"WHERE r.member_id = :memberId " +
"AND r.deleted_at IS NULL " +
"AND c.deleted_at IS NULL " +
"ORDER BY r.created_at DESC")
Flux<MemberCardInfoVO> findCardRecordsWithCardInfoByMemberId(Long memberId);
}
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
/**
* 会员卡记录 Repository(会员持有的卡)
*
*
* @author 付嘉
* @date 2026-05-27
*/
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.es.entity.MemberES;
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
import cn.novalon.gym.manage.member.vo.MemberInfoVO;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -58,4 +59,21 @@ public interface MemberService {
* @return 所有会员信息
*/
Flux<Member> findAll(Integer pageNum, Integer pageSize);
/**
* 前台管理端获取会员详情(含会员卡信息)
*
* @param memberId 会员ID
* @return 会员详情
*/
Mono<MemberDetailVO> getMemberDetail(Long memberId);
/**
* 前台管理端编辑会员信息
*
* @param memberId 会员ID
* @param updateDto 更新信息DTO
* @return 更新后的会员详情
*/
Mono<Boolean> adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto);
}
@@ -4,20 +4,31 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
import cn.novalon.gym.manage.common.exception.ErrorCode;
import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.common.exception.SystemException;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.es.entity.MemberES;
import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.service.MemberService;
import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.BeanConvertUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
import cn.novalon.gym.manage.member.vo.MemberInfoVO;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@@ -26,6 +37,10 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 会员服务实现
@@ -41,7 +56,6 @@ public class MemberServiceImpl implements MemberService {
private final IMemberRepository memberRepository;
private final MemberESRepository memberESRepository;
private final EsSyncUtils esSyncUtils;
private final WechatProperties wechatProperties;
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
@@ -67,10 +81,10 @@ public class MemberServiceImpl implements MemberService {
return memberRepository.findById(memberId)
.flatMap(member -> {
if (updateDto.getNickname() != null) {
member.setNickname(updateDto.getNickname());
member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname()));
}
if (updateDto.getGender() != null) {
member.setGender(updateDto.getGender());
member.setGender(updateDto.getGender().getCode());
}
if (updateDto.getBirthday() != null) {
member.setBirthday(updateDto.getBirthday());
@@ -79,7 +93,7 @@ public class MemberServiceImpl implements MemberService {
member.setAvatar(updateDto.getAvatar());
}
if (updateDto.getAddress() != null) {
member.setAddress(updateDto.getAddress());
member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress()));
}
return memberRepository.save(member);
@@ -99,11 +113,14 @@ public class MemberServiceImpl implements MemberService {
String phone = member.getPhone();
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
return MemberInfoVO.builder()
.id(member.getId())
.nickname(member.getNickname())
.phone(maskedPhone)
.gender(member.getGender())
.gender(genderEnum)
.genderDesc(genderEnum.getDesc())
.birthday(member.getBirthday())
.avatar(member.getAvatar())
.hasPhone(phone != null)
@@ -117,9 +134,7 @@ public class MemberServiceImpl implements MemberService {
String encryptedPhone;
try {
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
encryptedPhone = AesUtil.encrypt(phone, secretKey, iv);
encryptedPhone = AesUtil.encrypt(phone);
log.info("手机号加密成功");
} catch (Exception e) {
log.error("手机号加密失败", e);
@@ -147,7 +162,7 @@ public class MemberServiceImpl implements MemberService {
public Flux<MemberES> searchMember(SearchMemberDto searchMemberDto) {
log.info("搜索会员, searchValue: {}, filter: {}, pageNum: {}, pageSize: {}",
searchMemberDto.getSearchValue(),
searchMemberDto.getFilter(),
searchMemberDto.getGender(),
searchMemberDto.getPageNum(),
searchMemberDto.getPageSize());
@@ -155,22 +170,23 @@ public class MemberServiceImpl implements MemberService {
if(searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")){
log.debug("搜索值为手机号格式,进行加密处理");
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
searchValue = AesUtil.encrypt(searchValue,secretKey,iv);
searchValue = AesUtil.encrypt(searchValue);
}
Pageable pageable = PageRequest.of(
searchMemberDto.getPageNum() - 1,
searchMemberDto.getPageSize(),
Sort.by(Sort.Direction.DESC, "update_at")
searchMemberDto.getPageSize()
);
return memberESRepository.findByMemberNoOrPhoneOrNicknameContainingAndGender(
if (searchValue == null) {
log.warn("搜索值为空,返回空结果");
return Flux.empty();
}
return memberESRepository.findByMemberNoOrPhoneOrNicknameContaining(
searchValue,
searchValue,
searchValue,
searchMemberDto.getFilter() ,
pageable
);
}
@@ -187,6 +203,86 @@ public class MemberServiceImpl implements MemberService {
return memberRepository.findAllBy(pageable);
}
@Override
public Mono<MemberDetailVO> getMemberDetail(Long memberId) {
log.info("查询会员详情, memberId: {}", memberId);
return memberRepository.findById(memberId)
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
memberDetailVO.setGenderDesc(genderEnum.getDesc());
List<MemberCardInfoVO> 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);
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;
}
);
}
@Override
public Mono<Boolean> adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto) {
log.info("前台管理端编辑会员信息, memberId: {}", memberId);
return memberRepository.findById(memberId)
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}))
.flatMap(member -> {
log.error("有用户");
if (updateDto.getNickname() != null) {
member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname()));
}
if (updateDto.getGender() != null) {
member.setGender(updateDto.getGender().getCode());
}
if (updateDto.getBirthday() != null) {
member.setBirthday(updateDto.getBirthday());
}
if (updateDto.getAvatar() != null) {
member.setAvatar(updateDto.getAvatar());
}
if (updateDto.getAddress() != null) {
member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress()));
}
return memberRepository.save(member);
})
.doOnSuccess(memberSyncer::sync)
.map(savedMember -> true)
.onErrorResume(e -> {
log.error("编辑会员信息失败, memberId: {}, error: {}", memberId, e.getMessage(), e);
return Mono.just(false);
});
}
private Mono<Boolean> updateMemberPhone(Long memberId, String encryptedPhone) {
return memberRepository.findById(memberId)
.flatMap(member -> {
@@ -205,4 +301,4 @@ public class MemberServiceImpl implements MemberService {
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
}
}
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
import cn.novalon.gym.manage.member.entity.RefundApplication;
import cn.novalon.gym.manage.member.enums.RefundStatus;
import cn.novalon.gym.manage.member.repository.RefundApplicationRepository;
@@ -38,7 +39,7 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
.then(Mono.defer(() -> {
RefundApplication application = RefundApplication.builder()
.recordId(recordId)
.reason(reason)
.reason(HtmlEscapeUtil.escape(reason))
.status(RefundStatus.PENDING)
.applyTime(LocalDateTime.now())
.build();
@@ -58,7 +59,7 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
}
return refundApplicationRepository.approve(applicationId, "APPROVED", auditorId, remark)
return refundApplicationRepository.approve(applicationId, "APPROVED", auditorId, HtmlEscapeUtil.escape(remark))
.thenReturn(application)
.doOnSuccess(app -> log.info("批准退款申请成功: applicationId={}, auditorId={}",
applicationId, auditorId));
@@ -74,7 +75,7 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
}
return refundApplicationRepository.approve(applicationId, "REJECTED", auditorId, remark)
return refundApplicationRepository.approve(applicationId, "REJECTED", auditorId, HtmlEscapeUtil.escape(remark))
.thenReturn(application)
.doOnSuccess(app -> log.info("拒绝退款申请成功: applicationId={}, auditorId={}",
applicationId, auditorId));
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
import cn.novalon.gym.manage.common.exception.ErrorCode;
import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.common.exception.SystemException;
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
import cn.novalon.gym.manage.member.entity.Member;
@@ -42,7 +43,6 @@ public class WechatAuthServiceImpl implements WechatAuthService {
private final WechatApiService wechatApiService;
private final IMemberRepository memberRepository;
private final WechatProperties wechatProperties;
private final WechatPhoneUtil wechatPhoneUtil;
private final MemberESRepository memberESRepository;
private final EsSyncUtils esSyncUtils;
@@ -199,10 +199,7 @@ public class WechatAuthServiceImpl implements WechatAuthService {
private String encryptPhone(String phoneNumber) {
try {
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
String encryptedPhone = AesUtil.encrypt(phoneNumber, secretKey, iv);
String encryptedPhone = AesUtil.encrypt(phoneNumber);
log.debug("手机号加密成功");
return encryptedPhone;
@@ -214,10 +211,8 @@ public class WechatAuthServiceImpl implements WechatAuthService {
public String decryptPhone(String encryptedPhone) {
try {
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
String iv = wechatProperties.getPhoneEncryption().getIv();
String phoneNumber = AesUtil.decrypt(encryptedPhone, secretKey, iv);
String phoneNumber = AesUtil.decrypt(encryptedPhone);
log.debug("手机号解密成功");
return phoneNumber;
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.es.entity.MemberES;
@@ -74,11 +75,11 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
existingMember.setOfficialOpenId(openId);
if (existingMember.getNickname() == null || existingMember.getNickname().isEmpty()) {
existingMember.setNickname(userInfo.getNickname());
existingMember.setNickname(HtmlEscapeUtil.escape(userInfo.getNickname()));
}
if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) {
existingMember.setAvatar(userInfo.getHeadimgurl());
existingMember.setAvatar(HtmlEscapeUtil.escape(userInfo.getHeadimgurl()));
}
return memberRepository.save(existingMember)
@@ -1,12 +1,24 @@
package cn.novalon.gym.manage.member.util;
import cn.novalon.gym.manage.member.config.WechatProperties;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.checkerframework.checker.units.qual.K;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* AES加密工具类
@@ -16,24 +28,35 @@ import java.util.Base64;
*/
@Slf4j
@Component
public class AesUtil {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
private static String KEY;
private static String IV;
@Autowired
public void setWechatProperties(WechatProperties props) {
KEY = props.getPhoneEncryption().getSecretKey(); // 从配置类读取
IV = props.getPhoneEncryption().getIv();
if(KEY == null || IV == null) throw new RuntimeException("请配置AES密钥和偏移量");
}
/**
* AES 解密
*
* @param encryptedData 加密数据,Base64编码
* @param key AES密钥,Base64编码(32字节)
* @param iv 初始化向量IV,Base64编码(16字节)
* @return 解密后的字符串
*/
public static String decrypt(String encryptedData, String key, String iv) {
public static String decrypt(String encryptedData) {
try {
byte[] dataByte = Base64.getDecoder().decode(encryptedData);
byte[] keyByte = Base64.getDecoder().decode(key);
byte[] ivByte = Base64.getDecoder().decode(iv);
byte[] keyByte = Base64.getDecoder().decode(KEY);
byte[] ivByte = Base64.getDecoder().decode(IV);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
@@ -52,15 +75,13 @@ public class AesUtil {
* AES 加密
*
* @param data 原始数据
* @param key AES密钥,Base64编码(32字节)
* @param iv 初始化向量IVBase64编码(16字节)
* @return Base64编码的加密数据
*/
public static String encrypt(String data, String key, String iv) {
public static String encrypt(String data) {
try {
byte[] dataByte = data.getBytes(StandardCharsets.UTF_8);
byte[] keyByte = Base64.getDecoder().decode(key);
byte[] ivByte = Base64.getDecoder().decode(iv);
byte[] keyByte = Base64.getDecoder().decode(KEY);
byte[] ivByte = Base64.getDecoder().decode(IV);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
@@ -56,7 +56,7 @@ public class WechatPhoneUtil {
* @param phone 手机号
* @return 脱敏后的手机号,如:138****8000
*/
private String maskPhone(String phone) {
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
@@ -0,0 +1,92 @@
package cn.novalon.gym.manage.member.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 会员卡类型响应 VO
*
* @author 付嘉
* @date 2026-05-27
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberCardInfoVO {
/**
* 主键ID
*/
private Long id;
/**
* 会员卡ID
*/
private Long memberCardId;
/**
* 会员卡名称
*/
private String memberCardName;
/**
* 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡
*/
private String memberCardType;
/**
* 卡类型描述
*/
private String memberCardTypeDesc;
/**
* 会员卡价格
*/
private BigDecimal memberCardPrice;
/**
* 有效天数(时长卡用)
*/
private Integer memberCardValidityDays;
/**
* 总次数(次卡用)
*/
private Integer memberCardTotalTimes;
/**
* 面额(储值卡用)
*/
private BigDecimal memberCardAmount;
/**
* 状态:0-下架, 1-上架
*/
private Integer memberCardStatus;
/**
* 状态描述
*/
private String memberCardStatusDesc;
/**
* 扩展配置(JSON格式)
*/
private String extraConfig;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
}
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
/**
* 会员卡记录响应 VO
*
*
* @author 付嘉
* @date 2026-05-27
*/
@@ -68,4 +68,4 @@ public class MemberCardRecordVO {
* 创建时间
*/
private LocalDateTime createdAt;
}
}
@@ -0,0 +1,96 @@
package cn.novalon.gym.manage.member.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
/**
* 会员详情 VO(管理端使用)
*
* @author 付嘉
* @date 2026-05-27
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberDetailVO {
// ==================== 会员基础信息 ====================
/**
* 会员ID
*/
private Long id;
/**
* 会员编号
*/
private String memberNo;
/**
* 昵称
*/
private String nickname;
/**
* 手机号(脱敏显示)
*/
private String phone;
/**
* 性别描述
*/
private String genderDesc;
/**
* 生日
*/
private Date birthday;
/**
* 地址
*/
private String address;
/**
* 头像URL
*/
private String avatar;
/**
* 是否关注服务号
*/
private Boolean subscribed;
/**
* 最后登录时间
*/
private LocalDateTime lastLoginAt;
/**
* 注册时间
*/
private LocalDateTime createdAt;
// ==================== 会员卡信息 ====================
/**
* 会员持有的卡列表
*/
private List<MemberCardInfoVO> memberCards;
/**
* 有效会员卡数量
*/
private Integer activeCardCount;
/**
* 过期/用完会员卡数量
*/
private Integer inactiveCardCount;
}
@@ -1,15 +1,17 @@
package cn.novalon.gym.manage.member.vo;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.Date;
/**
* 会员信息 VO
*
*
* @author 付嘉
* @date 2026-05-01
*/
@@ -30,10 +32,13 @@ public class MemberInfoVO {
private String phone;
// 性别
private Integer gender;
private GenderEnum gender;
// 性别描述
private String genderDesc;
// 生日
private Date birthday;
private LocalDate birthday;
// 头像
private String avatar;
@@ -43,4 +48,4 @@ public class MemberInfoVO {
// 是否已关注公众号
private Boolean isSubscribed;
}
}
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS member_user (
nickname VARCHAR(100), -- 昵称
phone VARCHAR(255), -- 手机号(AES加密存储)
gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女
birthday TIMESTAMP, -- 生日
birthday DATE, -- 生日
address VARCHAR(500), -- 地址
avatar VARCHAR(500), -- 头像URL
subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
@@ -10,6 +10,8 @@ import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDeta
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.web.server.WebFilter;
@@ -17,9 +19,13 @@ import java.util.List;
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
ReactiveUserDetailsServiceAutoConfiguration.class })
@EnableR2dbcRepositories(basePackages = { "cn.novalon.gym.manage.db.dao",
@EnableR2dbcRepositories(basePackages = {
"cn.novalon.gym.manage.db.dao",
"cn.novalon.gym.manage.sys.audit.repository" ,
"cn.novalon.gym.manage.gymmembercard.dao"})
"cn.novalon.gym.manage.gymmembercard.dao",
"cn.novalon.gym.manage.member.repository"
})
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
public class ManageApplication {
private static final Logger logger = LoggerFactory.getLogger(ManageApplication.class);
@@ -0,0 +1,117 @@
package cn.novalon.gym.manage.common.util;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* HTML 转义工具类
* 防止 XSS 注入攻击
*
* @author 付嘉
* @date 2026-05-29
*/
public class HtmlEscapeUtil {
private static final Map<Character, String> ESCAPE_MAP = new HashMap<>();
private static final Map<String, Character> UNESCAPE_MAP = new HashMap<>();
private static final Pattern HTML_PATTERN = Pattern.compile("<[^>]*>");
static {
// HTML 特殊字符转义映射
ESCAPE_MAP.put('&', "&amp;");
ESCAPE_MAP.put('<', "&lt;");
ESCAPE_MAP.put('>', "&gt;");
ESCAPE_MAP.put('"', "&quot;");
ESCAPE_MAP.put('\'', "&#39;");
// 反向映射
UNESCAPE_MAP.put("&amp;", '&');
UNESCAPE_MAP.put("&lt;", '<');
UNESCAPE_MAP.put("&gt;", '>');
UNESCAPE_MAP.put("&quot;", '"');
UNESCAPE_MAP.put("&#39;", '\'');
}
/**
* 转义 HTML 特殊字符
*
* @param input 原始字符串
* @return 转义后的字符串
*/
public static String escape(String input) {
if (input == null || input.isEmpty()) {
return input;
}
StringBuilder result = new StringBuilder();
for (char c : input.toCharArray()) {
String escaped = ESCAPE_MAP.get(c);
if (escaped != null) {
result.append(escaped);
} else {
result.append(c);
}
}
return result.toString();
}
/**
* 反转义 HTML 特殊字符
*
* @param input 转义后的字符串
* @return 原始字符串
*/
public static String unescape(String input) {
if (input == null || input.isEmpty()) {
return input;
}
String result = input;
for (Map.Entry<String, Character> entry : UNESCAPE_MAP.entrySet()) {
result = result.replace(entry.getKey(), String.valueOf(entry.getValue()));
}
return result;
}
/**
* 移除所有 HTML 标签
*
* @param input 原始字符串
* @return 移除标签后的字符串
*/
public static String stripHtmlTags(String input) {
if (input == null || input.isEmpty()) {
return input;
}
return HTML_PATTERN.matcher(input).replaceAll("");
}
/**
* 安全转义(转义 + 移除标签)
*
* @param input 原始字符串
* @return 安全字符串
*/
public static String sanitize(String input) {
if (input == null || input.isEmpty()) {
return input;
}
// 先移除 HTML 标签,再转义特殊字符
String noTags = stripHtmlTags(input);
return escape(noTags);
}
/**
* 判断是否包含 HTML 标签
*
* @param input 原始字符串
* @return true-包含, false-不包含
*/
public static boolean containsHtmlTags(String input) {
if (input == null || input.isEmpty()) {
return false;
}
return HTML_PATTERN.matcher(input).find();
}
}