Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3907fcbffb | ||
|
|
1ef00dcfff | ||
|
|
52d0518892 | ||
|
|
a15712c150 | ||
|
|
1a6b894ff3 | ||
|
|
cd2fc12911 | ||
|
|
174e33053e | ||
|
|
29b73c1f67 | ||
|
|
493ecd3021 | ||
|
|
7340f02102 | ||
|
|
68fefae774 | ||
|
|
ffb3f20774 |
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-manage-api</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-groupCourse</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>gym-groupCourse</name>
|
||||||
|
<description>Group Course Management Module</description>
|
||||||
|
<url/>
|
||||||
|
<licenses>
|
||||||
|
<license/>
|
||||||
|
</licenses>
|
||||||
|
<developers>
|
||||||
|
<developer/>
|
||||||
|
</developers>
|
||||||
|
<scm>
|
||||||
|
<connection/>
|
||||||
|
<developerConnection/>
|
||||||
|
<tag/>
|
||||||
|
<url/>
|
||||||
|
</scm>
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-db</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.swagger.core.v3</groupId>
|
||||||
|
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||||
|
<version>2.2.43</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Redis依赖-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.converter;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课相关转换器
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class GroupCourseConverter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课实体转换为领域模型
|
||||||
|
*/
|
||||||
|
public GroupCourse toDomain(GroupCourseEntity entity){
|
||||||
|
if(entity == null){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
GroupCourse groupCourse = new GroupCourse();
|
||||||
|
BeanUtil.copyProperties(entity,groupCourse);
|
||||||
|
log.info("转换bean,entity-domain:",groupCourse);
|
||||||
|
return groupCourse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课领域模型转换为实体
|
||||||
|
*/
|
||||||
|
public GroupCourseEntity toEntity(GroupCourse domain){
|
||||||
|
if(domain == null){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
GroupCourseEntity entity = new GroupCourseEntity();
|
||||||
|
BeanUtil.copyProperties(domain,entity);
|
||||||
|
log.info("转换bean,domain-entity:",entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课实体列表转换为领域模型列表
|
||||||
|
*/
|
||||||
|
public List<GroupCourse> toDomainList(List<GroupCourseEntity> entities){
|
||||||
|
if (entities == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entities.stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课领域模型列表转换为实体列表
|
||||||
|
*/
|
||||||
|
public List<GroupCourseEntity> toEntityList(List<GroupCourse> domains){
|
||||||
|
if (domains == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return domains.stream()
|
||||||
|
.map(this::toEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课预约实体转换为领域模型
|
||||||
|
*/
|
||||||
|
public GroupCourseBooking toBookingDomain(GroupCourseBookingEntity entity){
|
||||||
|
if(entity == null){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
GroupCourseBooking booking = new GroupCourseBooking();
|
||||||
|
BeanUtil.copyProperties(entity, booking);
|
||||||
|
log.debug("转换预约记录实体到领域模型:bookingId={}", entity.getId());
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课预约领域模型转换为实体
|
||||||
|
*/
|
||||||
|
public GroupCourseBookingEntity toBookingEntity(GroupCourseBooking domain){
|
||||||
|
if(domain == null){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
GroupCourseBookingEntity entity = new GroupCourseBookingEntity();
|
||||||
|
BeanUtil.copyProperties(domain, entity);
|
||||||
|
if (domain.getId() != null) {
|
||||||
|
entity.markNotNew();
|
||||||
|
}
|
||||||
|
log.debug("转换预约记录领域模型到实体:bookingId={}", domain.getId());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课预约实体列表转换为领域模型列表
|
||||||
|
*/
|
||||||
|
public List<GroupCourseBooking> toBookingDomainList(List<GroupCourseBookingEntity> entities){
|
||||||
|
if (entities == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entities.stream()
|
||||||
|
.map(this::toBookingDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将团课预约领域模型列表转换为实体列表
|
||||||
|
*/
|
||||||
|
public List<GroupCourseBookingEntity> toBookingEntityList(List<GroupCourseBooking> domains){
|
||||||
|
if (domains == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return domains.stream()
|
||||||
|
.map(this::toBookingEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约记录DAO接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookingEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询未删除的预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBookingEntity> findByIdIsAndDeletedAtIsNull(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询所有预约记录
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBookingEntity> findByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询所有预约记录(带排序)
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBookingEntity> findByMemberIdAndDeletedAtIsNull(Long memberId, Sort sort);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据团课ID查询所有预约记录
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBookingEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据团课ID和会员ID查询预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBookingEntity> findByCourseIdAndMemberIdAndDeletedAtIsNull(Long courseId, Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据团课ID和状态查询预约记录
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBookingEntity> findByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员在指定课程的有效预约(状态为已预约且未取消)
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBookingEntity> findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(Long courseId, Long memberId, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员卡ID查询预约记录
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBookingEntity> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计团课已预约人数
|
||||||
|
*/
|
||||||
|
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
|
||||||
|
|
||||||
|
Mono<GroupCourseEntity> findByIdIsAndDeletedAtIsNull(Long id);
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findAll();
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findAll(Sort sort);
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findAllByDeletedAtIsNull();
|
||||||
|
|
||||||
|
Flux<GroupCourseEntity> findAllByDeletedAtIsNull(Sort sort);
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.domain;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class GroupCourse extends BaseDomain{
|
||||||
|
|
||||||
|
//课程名称
|
||||||
|
@Schema(description = "团课名", example = "Push-up")
|
||||||
|
private String courseName;
|
||||||
|
|
||||||
|
//教练id
|
||||||
|
@Schema(description = "教练id", example = "1")
|
||||||
|
private Long coachId;
|
||||||
|
|
||||||
|
//课程类型
|
||||||
|
@Schema(description = "课程类型", example = "1")
|
||||||
|
private Long courseType;
|
||||||
|
|
||||||
|
//开始时间
|
||||||
|
@Schema(description = "开始时间", example = "2026-01-01")
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
//结束时间
|
||||||
|
@Schema(description = "结束时间", example = "2026-01-02")
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
//最大参与人数
|
||||||
|
@Schema(description = "最大参与人数", example = "20")
|
||||||
|
private Integer maxMembers;
|
||||||
|
|
||||||
|
//当前参与人数
|
||||||
|
@Schema(description = "当前参与人数", example = "2")
|
||||||
|
private Integer currentMembers;
|
||||||
|
|
||||||
|
//课程状态:0-正常,1-已取消,2-已结束
|
||||||
|
@Schema(description = "课程状态", example = "0")
|
||||||
|
private Long status;
|
||||||
|
|
||||||
|
//上课地点
|
||||||
|
@Schema(description = "上课地点", example = "龙泉驿区幸福路")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
//封面图URL
|
||||||
|
@Schema(description = "封面图URL", example = "https://12345.com")
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
//课程描述
|
||||||
|
@Schema(description = "课程描述", example = "从入门到入土")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
public String getCourseName() {
|
||||||
|
return courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseName(String courseName) {
|
||||||
|
this.courseName = courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCoachId() {
|
||||||
|
return coachId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoachId(Long coachId) {
|
||||||
|
this.coachId = coachId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCourseType() {
|
||||||
|
return courseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseType(Long courseType) {
|
||||||
|
this.courseType = courseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getStartTime() {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartTime(LocalDateTime startTime) {
|
||||||
|
this.startTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getEndTime() {
|
||||||
|
return endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndTime(LocalDateTime endTime) {
|
||||||
|
this.endTime = endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMaxMembers() {
|
||||||
|
return maxMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxMembers(Integer maxMembers) {
|
||||||
|
this.maxMembers = maxMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCurrentMembers() {
|
||||||
|
return currentMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentMembers(Integer currentMembers) {
|
||||||
|
this.currentMembers = currentMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(Long status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverImage() {
|
||||||
|
return coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImage(String coverImage) {
|
||||||
|
this.coverImage = coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.domain;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约记录领域模型
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
public class GroupCourseBooking extends BaseDomain {
|
||||||
|
|
||||||
|
//团课ID
|
||||||
|
@Schema(description = "团课ID", example = "1")
|
||||||
|
private Long courseId;
|
||||||
|
|
||||||
|
//团课名称(关联查询)
|
||||||
|
@Schema(description = "团课名称", example = "瑜伽入门")
|
||||||
|
private String courseName;
|
||||||
|
|
||||||
|
//会员ID
|
||||||
|
@Schema(description = "会员ID", example = "1")
|
||||||
|
private Long memberId;
|
||||||
|
|
||||||
|
//会员卡ID
|
||||||
|
@Schema(description = "会员卡ID", example = "1")
|
||||||
|
private Long memberCardId;
|
||||||
|
|
||||||
|
//预约时间
|
||||||
|
@Schema(description = "预约时间", example = "2026-06-01 10:00:00")
|
||||||
|
private LocalDateTime bookingTime;
|
||||||
|
|
||||||
|
//状态:0-已预约,1-已取消,2-已出席,3-缺席
|
||||||
|
@Schema(description = "状态:0-已预约,1-已取消,2-已出席,3-缺席", example = "0")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
//取消时间
|
||||||
|
@Schema(description = "取消时间", example = "2026-06-01 11:00:00")
|
||||||
|
private LocalDateTime cancelTime;
|
||||||
|
|
||||||
|
//课程开始时间
|
||||||
|
@Schema(description = "课程开始时间", example = "2026-06-02 09:00:00")
|
||||||
|
private LocalDateTime courseStartTime;
|
||||||
|
|
||||||
|
//课程结束时间
|
||||||
|
@Schema(description = "课程结束时间", example = "2026-06-02 10:00:00")
|
||||||
|
private LocalDateTime courseEndTime;
|
||||||
|
|
||||||
|
//上课地点
|
||||||
|
@Schema(description = "上课地点", example = "健身房A区")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
public Long getCourseId() {
|
||||||
|
return courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseId(Long courseId) {
|
||||||
|
this.courseId = courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCourseName() {
|
||||||
|
return courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseName(String courseName) {
|
||||||
|
this.courseName = courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMemberId(Long memberId) {
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberCardId() {
|
||||||
|
return memberCardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMemberCardId(Long memberCardId) {
|
||||||
|
this.memberCardId = memberCardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getBookingTime() {
|
||||||
|
return bookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBookingTime(LocalDateTime bookingTime) {
|
||||||
|
this.bookingTime = bookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCancelTime() {
|
||||||
|
return cancelTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancelTime(LocalDateTime cancelTime) {
|
||||||
|
this.cancelTime = cancelTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCourseStartTime() {
|
||||||
|
return courseStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseStartTime(LocalDateTime courseStartTime) {
|
||||||
|
this.courseStartTime = courseStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCourseEndTime() {
|
||||||
|
return courseEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseEndTime(LocalDateTime courseEndTime) {
|
||||||
|
this.courseEndTime = courseEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.entity;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.db.entity.BaseEntity;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约记录实体类 - 对应 group_course_booking 表
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Table("group_course_booking")
|
||||||
|
public class GroupCourseBookingEntity extends BaseEntity {
|
||||||
|
|
||||||
|
//团课ID
|
||||||
|
@Column("course_id")
|
||||||
|
private Long courseId;
|
||||||
|
|
||||||
|
//会员ID
|
||||||
|
@Column("member_id")
|
||||||
|
private Long memberId;
|
||||||
|
|
||||||
|
//会员卡ID
|
||||||
|
@Column("member_card_id")
|
||||||
|
private Long memberCardId;
|
||||||
|
|
||||||
|
//预约时间
|
||||||
|
@Column("booking_time")
|
||||||
|
private LocalDateTime bookingTime;
|
||||||
|
|
||||||
|
//状态:0-已预约,1-已取消,2-已出席,3-缺席
|
||||||
|
@Column("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
//取消时间
|
||||||
|
@Column("cancel_time")
|
||||||
|
private LocalDateTime cancelTime;
|
||||||
|
|
||||||
|
//课程名称(冗余字段,保存预约时的课程快照)
|
||||||
|
@Column("course_name")
|
||||||
|
private String courseName;
|
||||||
|
|
||||||
|
//课程开始时间(冗余字段,保存预约时的课程快照)
|
||||||
|
@Column("course_start_time")
|
||||||
|
private LocalDateTime courseStartTime;
|
||||||
|
|
||||||
|
//课程结束时间(冗余字段,保存预约时的课程快照)
|
||||||
|
@Column("course_end_time")
|
||||||
|
private LocalDateTime courseEndTime;
|
||||||
|
|
||||||
|
//上课地点(冗余字段,保存预约时的课程快照)
|
||||||
|
@Column("location")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
public Long getCourseId() {
|
||||||
|
return courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseId(Long courseId) {
|
||||||
|
this.courseId = courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMemberId(Long memberId) {
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberCardId() {
|
||||||
|
return memberCardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMemberCardId(Long memberCardId) {
|
||||||
|
this.memberCardId = memberCardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getBookingTime() {
|
||||||
|
return bookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBookingTime(LocalDateTime bookingTime) {
|
||||||
|
this.bookingTime = bookingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCancelTime() {
|
||||||
|
return cancelTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancelTime(LocalDateTime cancelTime) {
|
||||||
|
this.cancelTime = cancelTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCourseName() {
|
||||||
|
return courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseName(String courseName) {
|
||||||
|
this.courseName = courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCourseStartTime() {
|
||||||
|
return courseStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseStartTime(LocalDateTime courseStartTime) {
|
||||||
|
this.courseStartTime = courseStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCourseEndTime() {
|
||||||
|
return courseEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseEndTime(LocalDateTime courseEndTime) {
|
||||||
|
this.courseEndTime = courseEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.entity;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.db.entity.BaseEntity;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Table("group_course")
|
||||||
|
public class GroupCourseEntity extends BaseEntity {
|
||||||
|
|
||||||
|
//课程名称
|
||||||
|
@Column("course_name")
|
||||||
|
private String courseName;
|
||||||
|
|
||||||
|
//教练id
|
||||||
|
@Column("coach_id")
|
||||||
|
private Long coachId;
|
||||||
|
|
||||||
|
//课程类型
|
||||||
|
@Column("course_type")
|
||||||
|
private Long courseType;
|
||||||
|
|
||||||
|
//开始时间
|
||||||
|
@Column("start_time")
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
//结束时间
|
||||||
|
@Column("end_time")
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
//最大参与人数
|
||||||
|
@Column("max_members")
|
||||||
|
private Integer maxMembers;
|
||||||
|
|
||||||
|
//当前参与人数
|
||||||
|
@Column("current_members")
|
||||||
|
private Integer currentMembers;
|
||||||
|
|
||||||
|
//课程状态:0-正常,1-已取消,2-已结束
|
||||||
|
@Column("status")
|
||||||
|
private Long status;
|
||||||
|
|
||||||
|
//上课地点
|
||||||
|
@Column("location")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
//封面图URL
|
||||||
|
@Column("cover_image")
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
//课程描述
|
||||||
|
@Column("description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
public String getCourseName() {
|
||||||
|
return courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseName(String courseName) {
|
||||||
|
this.courseName = courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCoachId() {
|
||||||
|
return coachId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoachId(Long coachId) {
|
||||||
|
this.coachId = coachId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCourseType() {
|
||||||
|
return courseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCourseType(Long courseType) {
|
||||||
|
this.courseType = courseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getStartTime() {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartTime(LocalDateTime startTime) {
|
||||||
|
this.startTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getEndTime() {
|
||||||
|
return endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndTime(LocalDateTime endTime) {
|
||||||
|
this.endTime = endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMaxMembers() {
|
||||||
|
return maxMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxMembers(Integer maxMembers) {
|
||||||
|
this.maxMembers = maxMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCurrentMembers() {
|
||||||
|
return currentMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentMembers(Integer currentMembers) {
|
||||||
|
this.currentMembers = currentMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(Long status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverImage() {
|
||||||
|
return coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImage(String coverImage) {
|
||||||
|
this.coverImage = coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.event;
|
||||||
|
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约提醒事件
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
public class BookingReminderEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息类型枚举
|
||||||
|
*/
|
||||||
|
public enum ReminderType {
|
||||||
|
BOOKING_SUCCESS, // 预约成功
|
||||||
|
COURSE_REMINDER, // 课程即将开始提醒
|
||||||
|
BOOKING_CANCEL // 预约取消
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Long bookingId;
|
||||||
|
private final Long memberId;
|
||||||
|
private final String courseName;
|
||||||
|
private final String courseTime;
|
||||||
|
private final ReminderType type;
|
||||||
|
|
||||||
|
public BookingReminderEvent(Object source, Long bookingId, Long memberId,
|
||||||
|
String courseName, String courseTime, ReminderType type) {
|
||||||
|
super(source);
|
||||||
|
this.bookingId = bookingId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
this.courseName = courseName;
|
||||||
|
this.courseTime = courseTime;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getBookingId() {
|
||||||
|
return bookingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCourseName() {
|
||||||
|
return courseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCourseTime() {
|
||||||
|
return courseTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReminderType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.event;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEvent.ReminderType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约提醒事件监听器
|
||||||
|
*
|
||||||
|
* 监听预约相关事件,处理提醒业务
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BookingReminderEventListener {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(BookingReminderEventListener.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理预约提醒事件
|
||||||
|
*
|
||||||
|
* @param event 预约提醒事件
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
public void handleBookingReminderEvent(BookingReminderEvent event) {
|
||||||
|
logger.info("收到预约提醒事件:type={}, bookingId={}, memberId={}",
|
||||||
|
event.getType(), event.getBookingId(), event.getMemberId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
processReminderEvent(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("处理预约提醒事件失败:bookingId={}, error={}",
|
||||||
|
event.getBookingId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理提醒事件
|
||||||
|
*
|
||||||
|
* 根据事件类型执行不同的提醒逻辑
|
||||||
|
*/
|
||||||
|
private void processReminderEvent(BookingReminderEvent event) {
|
||||||
|
switch (event.getType()) {
|
||||||
|
case BOOKING_SUCCESS:
|
||||||
|
handleBookingSuccess(event);
|
||||||
|
break;
|
||||||
|
case COURSE_REMINDER:
|
||||||
|
handleCourseReminder(event);
|
||||||
|
break;
|
||||||
|
case BOOKING_CANCEL:
|
||||||
|
handleBookingCancel(event);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.warn("未知的提醒事件类型:{}", event.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("预约提醒事件处理完成:bookingId={}", event.getBookingId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理预约成功事件
|
||||||
|
*/
|
||||||
|
private void handleBookingSuccess(BookingReminderEvent event) {
|
||||||
|
logger.info("处理预约成功提醒:会员ID={}, 课程={}, 时间={}",
|
||||||
|
event.getMemberId(), event.getCourseName(), event.getCourseTime());
|
||||||
|
|
||||||
|
// 实际业务中会调用通知服务发送短信、APP推送等
|
||||||
|
// sendNotification(event.getMemberId(), "预约成功",
|
||||||
|
// "您已成功预约课程:" + event.getCourseName() + ",时间:" + event.getCourseTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理课程即将开始提醒事件
|
||||||
|
*/
|
||||||
|
private void handleCourseReminder(BookingReminderEvent event) {
|
||||||
|
logger.info("处理课程提醒:会员ID={}, 课程={}, 时间={}",
|
||||||
|
event.getMemberId(), event.getCourseName(), event.getCourseTime());
|
||||||
|
|
||||||
|
// 实际业务中会调用通知服务发送课程开始前提醒
|
||||||
|
// sendNotification(event.getMemberId(), "课程提醒",
|
||||||
|
// "您预约的课程" + event.getCourseName() + "即将开始,时间:" + event.getCourseTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理预约取消事件
|
||||||
|
*/
|
||||||
|
private void handleBookingCancel(BookingReminderEvent event) {
|
||||||
|
logger.info("处理预约取消提醒:会员ID={}, 课程={}",
|
||||||
|
event.getMemberId(), event.getCourseName());
|
||||||
|
|
||||||
|
// 实际业务中会调用通知服务发送预约取消通知
|
||||||
|
// sendNotification(event.getMemberId(), "预约取消",
|
||||||
|
// "您已取消课程:" + event.getCourseName() + "的预约");
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.event;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEvent.ReminderType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约提醒事件发布器
|
||||||
|
*
|
||||||
|
* 使用Spring事件机制发布预约相关事件
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BookingReminderEventPublisher {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(BookingReminderEventPublisher.class);
|
||||||
|
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
public BookingReminderEventPublisher(ApplicationEventPublisher eventPublisher) {
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布预约成功事件
|
||||||
|
*
|
||||||
|
* @param bookingId 预约ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param courseName 课程名称
|
||||||
|
* @param courseTime 课程时间
|
||||||
|
*/
|
||||||
|
public void publishBookingSuccessEvent(Long bookingId, Long memberId, String courseName, String courseTime) {
|
||||||
|
BookingReminderEvent event = new BookingReminderEvent(
|
||||||
|
this, bookingId, memberId, courseName, courseTime, ReminderType.BOOKING_SUCCESS);
|
||||||
|
|
||||||
|
logger.info("发布预约成功事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
|
eventPublisher.publishEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布课程即将开始提醒事件
|
||||||
|
*
|
||||||
|
* @param bookingId 预约ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param courseName 课程名称
|
||||||
|
* @param courseTime 课程时间
|
||||||
|
*/
|
||||||
|
public void publishCourseReminderEvent(Long bookingId, Long memberId, String courseName, String courseTime) {
|
||||||
|
BookingReminderEvent event = new BookingReminderEvent(
|
||||||
|
this, bookingId, memberId, courseName, courseTime, ReminderType.COURSE_REMINDER);
|
||||||
|
|
||||||
|
logger.info("发布课程提醒事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
|
eventPublisher.publishEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布预约取消事件
|
||||||
|
*
|
||||||
|
* @param bookingId 预约ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param courseName 课程名称
|
||||||
|
*/
|
||||||
|
public void publishBookingCancelEvent(Long bookingId, Long memberId, String courseName) {
|
||||||
|
BookingReminderEvent event = new BookingReminderEvent(
|
||||||
|
this, bookingId, memberId, courseName, null, ReminderType.BOOKING_CANCEL);
|
||||||
|
|
||||||
|
logger.info("发布预约取消事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
|
eventPublisher.publishEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约Handler
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Tag(name = "团课预约", description = "团课预约相关操作")
|
||||||
|
public class GroupCourseBookingHandler {
|
||||||
|
|
||||||
|
private final IGroupCourseBookingService bookingService;
|
||||||
|
|
||||||
|
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) {
|
||||||
|
this.bookingService = bookingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约团课
|
||||||
|
*/
|
||||||
|
@Operation(summary = "预约团课", description = "会员预约指定团课")
|
||||||
|
public Mono<ServerResponse> bookCourse(ServerRequest request) {
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
Long courseId = ((Number) body.get("courseId")).longValue();
|
||||||
|
Long memberId = ((Number) body.get("memberId")).longValue();
|
||||||
|
Long memberCardId = ((Number) body.get("memberCardId")).longValue();
|
||||||
|
|
||||||
|
return bookingService.bookCourse(courseId, memberId, memberCardId)
|
||||||
|
.flatMap(booking -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "预约成功");
|
||||||
|
response.put("data", booking);
|
||||||
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", error.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消预约
|
||||||
|
*/
|
||||||
|
@Operation(summary = "取消预约", description = "会员取消已预约的团课")
|
||||||
|
public Mono<ServerResponse> cancelBooking(ServerRequest request) {
|
||||||
|
Long bookingId = Long.valueOf(request.pathVariable("bookingId"));
|
||||||
|
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
Long memberId = ((Number) body.get("memberId")).longValue();
|
||||||
|
|
||||||
|
return bookingService.cancelBooking(bookingId, memberId)
|
||||||
|
.flatMap(booking -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "取消成功");
|
||||||
|
response.put("data", booking);
|
||||||
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", error.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员预约记录
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询会员预约记录", description = "根据会员ID查询所有预约记录")
|
||||||
|
public Mono<ServerResponse> getBookingsByMemberId(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(bookingService.getBookingsByMemberId(memberId), GroupCourseBooking.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询预约详情
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询预约详情", description = "根据预约ID查询预约详情")
|
||||||
|
public Mono<ServerResponse> getBookingById(ServerRequest request) {
|
||||||
|
Long bookingId = Long.valueOf(request.pathVariable("bookingId"));
|
||||||
|
|
||||||
|
return bookingService.getBookingById(bookingId)
|
||||||
|
.flatMap(booking -> ServerResponse.ok().bodyValue(booking))
|
||||||
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课程预约记录
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询课程预约记录", description = "根据团课ID查询所有预约记录")
|
||||||
|
public Mono<ServerResponse> getBookingsByCourseId(ServerRequest request) {
|
||||||
|
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||||
|
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Validator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Tag(name="团课管理",description = "团课相关操作")
|
||||||
|
public class GroupCourseHandler {
|
||||||
|
private final IGroupCourseService groupCourseService;
|
||||||
|
private final Validator validator;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public GroupCourseHandler(IGroupCourseService groupCourseService,
|
||||||
|
Validator validator,
|
||||||
|
RedisUtil redisUtil,
|
||||||
|
ObjectMapper objectMapper){
|
||||||
|
this.groupCourseService = groupCourseService;
|
||||||
|
this.validator = validator;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
|
||||||
|
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
|
||||||
|
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
|
||||||
|
public Mono<ServerResponse> getGroupCoursesByPage(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PageRequest.class)
|
||||||
|
.flatMap(pageRequest -> {
|
||||||
|
boolean includeDeleted = request.queryParam("includeDeleted")
|
||||||
|
.map(Boolean::parseBoolean)
|
||||||
|
.orElse(false);
|
||||||
|
|
||||||
|
if (pageRequest.getPage() < 0) {
|
||||||
|
pageRequest.setPage(0);
|
||||||
|
}
|
||||||
|
if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) {
|
||||||
|
pageRequest.setSize(10);
|
||||||
|
}
|
||||||
|
if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) {
|
||||||
|
pageRequest.setSort("id");
|
||||||
|
}
|
||||||
|
if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) {
|
||||||
|
pageRequest.setOrder("asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupCourseService.findByPage(pageRequest, includeDeleted)
|
||||||
|
.flatMap(response -> ServerResponse.ok().bodyValue(response));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "根据ID获取团课", description = "根据ID获取团课详情")
|
||||||
|
public Mono<ServerResponse> getGroupCourseById(ServerRequest request){
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(groupCourseService.findById(id), GroupCourse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
|
||||||
|
public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
String key = (String) body.get("key");
|
||||||
|
|
||||||
|
if (key == null || key.isEmpty()) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("success", false);
|
||||||
|
error.put("message", "key参数不能为空");
|
||||||
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redisUtil.get(key)
|
||||||
|
.map(cachedValue -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
if (cachedValue != null) {
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("key", key);
|
||||||
|
result.put("value", cachedValue);
|
||||||
|
result.put("message", "缓存命中");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cachedValue instanceof String) {
|
||||||
|
Object jsonObject = objectMapper.readValue((String) cachedValue, Object.class);
|
||||||
|
result.put("parsedValue", jsonObject);
|
||||||
|
result.put("valueType", "JSON字符串");
|
||||||
|
} else {
|
||||||
|
result.put("valueType", cachedValue.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.put("parsedValue", null);
|
||||||
|
result.put("valueType", "无法解析");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("key", key);
|
||||||
|
result.put("value", null);
|
||||||
|
result.put("message", "缓存未命中");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
Map<String, Object> errorResponse = new HashMap<>();
|
||||||
|
errorResponse.put("success", false);
|
||||||
|
errorResponse.put("message", "请求处理失败: " + error.getMessage());
|
||||||
|
return ServerResponse.status(500).bodyValue(errorResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约记录Repository接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
public interface IGroupCourseBookingRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> findById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询预约记录列表
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBooking> findByMemberId(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据团课ID查询预约记录列表
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBooking> findByCourseId(Long courseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员是否已预约某课程
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> findByCourseIdAndMemberId(Long courseId, Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员在指定课程的有效预约
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> findValidBooking(Long courseId, Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计课程有效预约人数
|
||||||
|
*/
|
||||||
|
Mono<Long> countValidBookings(Long courseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> save(GroupCourseBooking booking);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> update(GroupCourseBooking booking);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员卡ID查询预约记录
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBooking> findByMemberCardId(Long memberCardId);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface IGroupCourseRepository {
|
||||||
|
Mono<GroupCourse> findByIdAndDeletedAtIsNull(Long id);
|
||||||
|
Flux<GroupCourse> findAll();
|
||||||
|
Flux<GroupCourse> findAll(Sort sort);
|
||||||
|
Flux<GroupCourse> findByDeletedAtIsNull();
|
||||||
|
Flux<GroupCourse> findByDeletedAtIsNull(Sort sort);
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest);
|
||||||
|
Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest);
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约记录Repository实现类
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class GroupCourseBookingRepository implements IGroupCourseBookingRepository {
|
||||||
|
|
||||||
|
private final GroupCourseBookingDao groupCourseBookingDao;
|
||||||
|
private final GroupCourseConverter groupCourseConverter;
|
||||||
|
|
||||||
|
public GroupCourseBookingRepository(GroupCourseBookingDao groupCourseBookingDao,
|
||||||
|
GroupCourseConverter groupCourseConverter) {
|
||||||
|
this.groupCourseBookingDao = groupCourseBookingDao;
|
||||||
|
this.groupCourseConverter = groupCourseConverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> findById(Long id) {
|
||||||
|
return groupCourseBookingDao.findByIdIsAndDeletedAtIsNull(id)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseBooking> findByMemberId(Long memberId) {
|
||||||
|
return groupCourseBookingDao.findByMemberIdAndDeletedAtIsNull(memberId, Sort.by(Sort.Direction.DESC, "bookingTime"))
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseBooking> findByCourseId(Long courseId) {
|
||||||
|
return groupCourseBookingDao.findByCourseIdAndDeletedAtIsNull(courseId)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> findByCourseIdAndMemberId(Long courseId, Long memberId) {
|
||||||
|
return groupCourseBookingDao.findByCourseIdAndMemberIdAndDeletedAtIsNull(courseId, memberId)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> findValidBooking(Long courseId, Long memberId) {
|
||||||
|
return groupCourseBookingDao.findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(courseId, memberId, "0")
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Long> countValidBookings(Long courseId) {
|
||||||
|
return groupCourseBookingDao.countByCourseIdAndStatusAndDeletedAtIsNull(courseId, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> save(GroupCourseBooking booking) {
|
||||||
|
GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking);
|
||||||
|
return groupCourseBookingDao.save(entity)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> update(GroupCourseBooking booking) {
|
||||||
|
GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking);
|
||||||
|
return groupCourseBookingDao.save(entity)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseBooking> findByMemberCardId(Long memberCardId) {
|
||||||
|
return groupCourseBookingDao.findByMemberCardIdAndDeletedAtIsNull(memberCardId)
|
||||||
|
.map(groupCourseConverter::toBookingDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
|
import org.springframework.data.relational.core.query.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class GroupCourseRepository implements IGroupCourseRepository {
|
||||||
|
private final GroupCourseDao groupCourseDao;
|
||||||
|
private final GroupCourseConverter groupCourseConverter;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
|
public GroupCourseRepository(GroupCourseDao groupCourseDao, GroupCourseConverter groupCourseConverter,
|
||||||
|
R2dbcEntityTemplate r2dbcEntityTemplate){
|
||||||
|
this.groupCourseDao = groupCourseDao;
|
||||||
|
this.groupCourseConverter = groupCourseConverter;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourse> findByIdAndDeletedAtIsNull(Long id) {
|
||||||
|
return groupCourseDao.findByIdIsAndDeletedAtIsNull(id)
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findAll() {
|
||||||
|
return groupCourseDao.findAll()
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findAll(Sort sort) {
|
||||||
|
return groupCourseDao.findAll(sort)
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findByDeletedAtIsNull() {
|
||||||
|
return groupCourseDao.findAllByDeletedAtIsNull()
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findByDeletedAtIsNull(Sort sort) {
|
||||||
|
return groupCourseDao.findAllByDeletedAtIsNull(sort)
|
||||||
|
.map(groupCourseConverter::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
|
||||||
|
|
||||||
|
Query query = Query.empty();
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.select(GroupCourseEntity.class)
|
||||||
|
.matching(query.with(pageable))
|
||||||
|
.all()
|
||||||
|
.collectList()
|
||||||
|
.zipWith(r2dbcEntityTemplate.count(query, GroupCourseEntity.class))
|
||||||
|
.map(tuple -> {
|
||||||
|
long total = tuple.getT2();
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
List<GroupCourse> courseList = tuple.getT1().stream()
|
||||||
|
.map(groupCourseConverter::toDomain)
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
|
||||||
|
|
||||||
|
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
|
||||||
|
.collectList()
|
||||||
|
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
|
||||||
|
.map(tuple -> {
|
||||||
|
List<GroupCourseEntity> allEntities = tuple.getT1();
|
||||||
|
long total = tuple.getT2();
|
||||||
|
|
||||||
|
int fromIndex = page * size;
|
||||||
|
int toIndex = Math.min(fromIndex + size, allEntities.size());
|
||||||
|
|
||||||
|
List<GroupCourse> courseList;
|
||||||
|
if (fromIndex < allEntities.size()) {
|
||||||
|
courseList = allEntities.subList(fromIndex, toIndex).stream()
|
||||||
|
.map(groupCourseConverter::toDomain)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
courseList = List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约服务接口
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
public interface IGroupCourseBookingService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约团课
|
||||||
|
*
|
||||||
|
* @param courseId 团课ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param memberCardId 会员卡ID
|
||||||
|
* @return 预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消预约
|
||||||
|
*
|
||||||
|
* @param bookingId 预约ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 取消后的预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> cancelBooking(Long bookingId, Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询预约记录列表
|
||||||
|
*
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 预约记录列表
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据预约ID查询预约详情
|
||||||
|
*
|
||||||
|
* @param bookingId 预约ID
|
||||||
|
* @return 预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> getBookingById(Long bookingId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据团课ID查询预约记录列表
|
||||||
|
*
|
||||||
|
* @param courseId 团课ID
|
||||||
|
* @return 预约记录列表
|
||||||
|
*/
|
||||||
|
Flux<GroupCourseBooking> getBookingsByCourseId(Long courseId);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface IGroupCourseService {
|
||||||
|
Mono<GroupCourse> findById(Long id);
|
||||||
|
Flux<GroupCourse> findAll();
|
||||||
|
Flux<GroupCourse> findAll(boolean includeDeleted);
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author:liwentao
|
||||||
|
* @date:2026/5/15-05-15-16:05
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RedisService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
// 设置值
|
||||||
|
public void set(String key, Object value) {
|
||||||
|
redisTemplate.opsForValue().set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置值并指定过期时间(秒)
|
||||||
|
public void setWithExpire(String key, Object value, long timeout) {
|
||||||
|
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取值
|
||||||
|
public Object get(String key) {
|
||||||
|
return redisTemplate.opsForValue().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除key
|
||||||
|
public Boolean delete(String key) {
|
||||||
|
return redisTemplate.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断key是否存在
|
||||||
|
public Boolean hasKey(String key) {
|
||||||
|
return redisTemplate.hasKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期时间
|
||||||
|
public Boolean expire(String key, long timeout) {
|
||||||
|
return redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash操作
|
||||||
|
public void putHash(String key, String hashKey, Object value) {
|
||||||
|
redisTemplate.opsForHash().put(key, hashKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object getHash(String key, String hashKey) {
|
||||||
|
return redisTemplate.opsForHash().get(key, hashKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List操作
|
||||||
|
public void leftPush(String key, Object value) {
|
||||||
|
redisTemplate.opsForList().leftPush(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object rightPop(String key) {
|
||||||
|
return redisTemplate.opsForList().rightPop(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set操作
|
||||||
|
public void addToSet(String key, Object... values) {
|
||||||
|
redisTemplate.opsForSet().add(key, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Object> getSet(String key) {
|
||||||
|
return redisTemplate.opsForSet().members(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课预约服务实现类
|
||||||
|
*
|
||||||
|
* 业务规则:
|
||||||
|
* - 预约需在课程开始前至少30分钟
|
||||||
|
* - 取消预约需在课程开始前至少2小时
|
||||||
|
* - 每节课最多20人
|
||||||
|
* - 预约成功后发送提醒
|
||||||
|
* - 预约成功后扣减权益
|
||||||
|
*
|
||||||
|
* 技术要点:
|
||||||
|
* - 使用Redis缓存团课信息
|
||||||
|
* - 使用分布式锁防止预约冲突
|
||||||
|
* - 使用响应式编程实现高并发预约
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseBookingService.class);
|
||||||
|
|
||||||
|
private final IGroupCourseBookingRepository bookingRepository;
|
||||||
|
private final IGroupCourseRepository courseRepository;
|
||||||
|
private final GroupCourseRedisService redisService;
|
||||||
|
private final BookingReminderEventPublisher bookingReminderEventPublisher;
|
||||||
|
|
||||||
|
// 预约提前时间限制(分钟)
|
||||||
|
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
|
||||||
|
// 取消预约提前时间限制(小时)
|
||||||
|
private static final long CANCEL_MIN_ADVANCE_HOURS = 2;
|
||||||
|
|
||||||
|
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
|
||||||
|
IGroupCourseRepository courseRepository,
|
||||||
|
GroupCourseRedisService redisService,
|
||||||
|
BookingReminderEventPublisher bookingReminderEventPublisher) {
|
||||||
|
this.bookingRepository = bookingRepository;
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.redisService = redisService;
|
||||||
|
this.bookingReminderEventPublisher = bookingReminderEventPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardId) {
|
||||||
|
logger.info("开始预约团课:courseId={}, memberId={}, memberCardId={}", courseId, memberId, memberCardId);
|
||||||
|
|
||||||
|
// 生成唯一请求ID用于分布式锁
|
||||||
|
String requestId = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
// 1. 获取分布式锁
|
||||||
|
return redisService.acquireLock(courseId, requestId)
|
||||||
|
.flatMap(lockAcquired -> {
|
||||||
|
if (!lockAcquired) {
|
||||||
|
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取
|
||||||
|
return getCourseWithCache(courseId)
|
||||||
|
.flatMap(course -> {
|
||||||
|
// 3. 验证课程状态
|
||||||
|
if (!"0".equals(String.valueOf(course.getStatus()))) {
|
||||||
|
return releaseLockAndError(courseId, requestId, "课程状态不可预约");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证预约时间
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime startTime = course.getStartTime();
|
||||||
|
long minutesUntilStart = ChronoUnit.MINUTES.between(now, startTime);
|
||||||
|
if (minutesUntilStart < BOOKING_MIN_ADVANCE_MINUTES) {
|
||||||
|
return releaseLockAndError(courseId, requestId,
|
||||||
|
"需在课程开始前" + BOOKING_MIN_ADVANCE_MINUTES + "分钟预约");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 验证是否已预约
|
||||||
|
return bookingRepository.findValidBooking(courseId, memberId)
|
||||||
|
.flatMap(existingBooking -> {
|
||||||
|
return releaseLockAndError(courseId, requestId, "您已预约该课程");
|
||||||
|
})
|
||||||
|
.switchIfEmpty(
|
||||||
|
// 6. 使用Redis原子操作验证课程人数是否已满
|
||||||
|
validateAndIncrementBookingCount(courseId, course.getMaxMembers())
|
||||||
|
.flatMap(countValid -> {
|
||||||
|
if (countValid > course.getMaxMembers()) {
|
||||||
|
return releaseLockAndError(courseId, requestId, "课程已满");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 创建预约记录
|
||||||
|
GroupCourseBooking booking = new GroupCourseBooking();
|
||||||
|
booking.setCourseId(courseId);
|
||||||
|
booking.setMemberId(memberId);
|
||||||
|
booking.setMemberCardId(memberCardId);
|
||||||
|
booking.setBookingTime(LocalDateTime.now());
|
||||||
|
booking.setStatus("0"); // 0-已预约
|
||||||
|
|
||||||
|
// 添加课程信息到预约记录
|
||||||
|
booking.setCourseName(course.getCourseName());
|
||||||
|
booking.setCourseStartTime(course.getStartTime());
|
||||||
|
booking.setCourseEndTime(course.getEndTime());
|
||||||
|
booking.setLocation(course.getLocation());
|
||||||
|
|
||||||
|
// 8. 保存预约记录
|
||||||
|
return bookingRepository.save(booking)
|
||||||
|
.flatMap(savedBooking -> {
|
||||||
|
// 9. 释放锁
|
||||||
|
return redisService.releaseLock(courseId, requestId)
|
||||||
|
.then(Mono.just(savedBooking));
|
||||||
|
})
|
||||||
|
.doOnSuccess(savedBooking -> {
|
||||||
|
logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
|
||||||
|
savedBooking.getId(), courseId, memberId);
|
||||||
|
// 发布预约成功事件
|
||||||
|
bookingReminderEventPublisher.publishBookingSuccessEvent(
|
||||||
|
savedBooking.getId(),
|
||||||
|
savedBooking.getMemberId(),
|
||||||
|
savedBooking.getCourseName(),
|
||||||
|
savedBooking.getCourseStartTime().toString()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.doOnError(error -> {
|
||||||
|
// 回滚Redis计数
|
||||||
|
redisService.decrementBookingCount(courseId).subscribe();
|
||||||
|
logger.error("预约失败:courseId={}, memberId={}, error={}",
|
||||||
|
courseId, memberId, error.getMessage());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
// 发生错误时释放锁
|
||||||
|
redisService.releaseLock(courseId, requestId).subscribe();
|
||||||
|
return Mono.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从缓存或数据库获取课程信息
|
||||||
|
*/
|
||||||
|
private Mono<GroupCourse> getCourseWithCache(Long courseId) {
|
||||||
|
return redisService.getCachedCourse(courseId)
|
||||||
|
.switchIfEmpty(
|
||||||
|
// 缓存未命中,从数据库查询
|
||||||
|
courseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||||
|
.flatMap(course -> {
|
||||||
|
// 将课程信息存入缓存
|
||||||
|
return redisService.cacheCourse(course).then(Mono.just(course));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并增加预约人数(使用Redis原子操作)
|
||||||
|
*/
|
||||||
|
private Mono<Integer> validateAndIncrementBookingCount(Long courseId, Integer maxMembers) {
|
||||||
|
return redisService.incrementBookingCount(courseId)
|
||||||
|
.map(count -> {
|
||||||
|
// 同时查询数据库中的预约数进行双重验证
|
||||||
|
// 这里简化处理,实际生产环境应该结合数据库查询
|
||||||
|
return count.intValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放锁并返回错误
|
||||||
|
*/
|
||||||
|
private Mono<GroupCourseBooking> releaseLockAndError(Long courseId, String requestId, String errorMessage) {
|
||||||
|
return redisService.releaseLock(courseId, requestId)
|
||||||
|
.then(Mono.error(new RuntimeException(errorMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> cancelBooking(Long bookingId, Long memberId) {
|
||||||
|
logger.info("开始取消预约:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
|
|
||||||
|
// 生成唯一请求ID用于分布式锁
|
||||||
|
String requestId = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
// 获取锁防止并发取消
|
||||||
|
return redisService.acquireLock(bookingId, requestId)
|
||||||
|
.flatMap(lockAcquired -> {
|
||||||
|
if (!lockAcquired) {
|
||||||
|
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 查询预约记录
|
||||||
|
return bookingRepository.findById(bookingId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("预约记录不存在")))
|
||||||
|
.flatMap(booking -> {
|
||||||
|
// 2. 验证预约归属
|
||||||
|
if (!booking.getMemberId().equals(memberId)) {
|
||||||
|
return releaseLockAndError(bookingId, requestId, "无权取消他人预约");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证预约状态
|
||||||
|
if (!"0".equals(booking.getStatus())) {
|
||||||
|
return releaseLockAndError(bookingId, requestId, "预约状态不允许取消");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证取消时间
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime startTime = booking.getCourseStartTime();
|
||||||
|
long hoursUntilStart = ChronoUnit.HOURS.between(now, startTime);
|
||||||
|
if (hoursUntilStart < CANCEL_MIN_ADVANCE_HOURS) {
|
||||||
|
return releaseLockAndError(bookingId, requestId,
|
||||||
|
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 更新预约状态
|
||||||
|
booking.setStatus("1"); // 1-已取消
|
||||||
|
booking.setCancelTime(LocalDateTime.now());
|
||||||
|
|
||||||
|
// 6. 保存更新
|
||||||
|
return bookingRepository.update(booking)
|
||||||
|
.flatMap(updatedBooking -> {
|
||||||
|
// 7. 减少Redis中的预约人数计数
|
||||||
|
return redisService.decrementBookingCount(booking.getCourseId())
|
||||||
|
.then(Mono.just(updatedBooking));
|
||||||
|
})
|
||||||
|
.flatMap(updatedBooking -> {
|
||||||
|
// 8. 释放锁
|
||||||
|
return redisService.releaseLock(bookingId, requestId)
|
||||||
|
.then(Mono.just(updatedBooking));
|
||||||
|
})
|
||||||
|
.doOnSuccess(updatedBooking -> {
|
||||||
|
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
|
||||||
|
// 发布预约取消事件
|
||||||
|
bookingReminderEventPublisher.publishBookingCancelEvent(
|
||||||
|
updatedBooking.getId(),
|
||||||
|
updatedBooking.getMemberId(),
|
||||||
|
updatedBooking.getCourseName()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.doOnError(error -> {
|
||||||
|
logger.error("取消预约失败:bookingId={}, memberId={}, error={}",
|
||||||
|
bookingId, memberId, error.getMessage());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
redisService.releaseLock(bookingId, requestId).subscribe();
|
||||||
|
return Mono.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
|
||||||
|
logger.debug("查询会员预约记录:memberId={}", memberId);
|
||||||
|
return bookingRepository.findByMemberId(memberId)
|
||||||
|
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> getBookingById(Long bookingId) {
|
||||||
|
logger.debug("查询预约详情:bookingId={}", bookingId);
|
||||||
|
return bookingRepository.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourseBooking> getBookingsByCourseId(Long courseId) {
|
||||||
|
logger.debug("查询课程预约记录:courseId={}", courseId);
|
||||||
|
return bookingRepository.findByCourseId(courseId)
|
||||||
|
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团课Redis缓存服务
|
||||||
|
*
|
||||||
|
* 负责团课信息的缓存管理和分布式锁实现
|
||||||
|
*
|
||||||
|
* @author 张翔
|
||||||
|
* @date 2026-06-01
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class GroupCourseRedisService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRedisService.class);
|
||||||
|
|
||||||
|
// 团课信息缓存Key前缀
|
||||||
|
private static final String GROUP_COURSE_CACHE_PREFIX = "group_course:";
|
||||||
|
// 团课预约锁Key前缀
|
||||||
|
private static final String BOOKING_LOCK_PREFIX = "booking_lock:";
|
||||||
|
// 缓存过期时间(5分钟)
|
||||||
|
private static final Duration CACHE_EXPIRE_TIME = Duration.ofMinutes(5);
|
||||||
|
// 锁过期时间(30秒)
|
||||||
|
private static final Duration LOCK_EXPIRE_TIME = Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
private final ReactiveRedisTemplate<String, Object> reactiveRedisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public GroupCourseRedisService(ReactiveRedisTemplate<String, Object> reactiveRedisTemplate,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.reactiveRedisTemplate = reactiveRedisTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取团课缓存Key
|
||||||
|
*/
|
||||||
|
private String getCourseCacheKey(Long courseId) {
|
||||||
|
return GROUP_COURSE_CACHE_PREFIX + courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预约锁Key
|
||||||
|
*/
|
||||||
|
private String getBookingLockKey(Long courseId) {
|
||||||
|
return BOOKING_LOCK_PREFIX + courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存团课信息
|
||||||
|
*/
|
||||||
|
public Mono<Void> cacheCourse(GroupCourse course) {
|
||||||
|
String key = getCourseCacheKey(course.getId());
|
||||||
|
try {
|
||||||
|
String value = objectMapper.writeValueAsString(course);
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.set(key, value, CACHE_EXPIRE_TIME)
|
||||||
|
.doOnSuccess(result -> logger.debug("团课信息已缓存:courseId={}", course.getId()))
|
||||||
|
.then();
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("序列化团课信息失败:courseId={}", course.getId(), e);
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的团课信息
|
||||||
|
*/
|
||||||
|
public Mono<GroupCourse> getCachedCourse(Long courseId) {
|
||||||
|
String key = getCourseCacheKey(courseId);
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.get(key)
|
||||||
|
.cast(String.class)
|
||||||
|
.flatMap(value -> {
|
||||||
|
try {
|
||||||
|
GroupCourse course = objectMapper.readValue(value, GroupCourse.class);
|
||||||
|
logger.debug("从缓存获取团课信息:courseId={}", courseId);
|
||||||
|
return Mono.just(course);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("反序列化团课信息失败:courseId={}", courseId, e);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.fromRunnable(() -> logger.debug("缓存中未找到团课信息:courseId={}", courseId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除团课缓存
|
||||||
|
*/
|
||||||
|
public Mono<Void> invalidateCourseCache(Long courseId) {
|
||||||
|
String key = getCourseCacheKey(courseId);
|
||||||
|
return reactiveRedisTemplate.delete(key)
|
||||||
|
.doOnSuccess(result -> logger.debug("团课缓存已删除:courseId={}", courseId))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分布式锁
|
||||||
|
*
|
||||||
|
* @param courseId 课程ID
|
||||||
|
* @param requestId 请求ID(用于锁的唯一性校验)
|
||||||
|
* @return 是否获取成功
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> acquireLock(Long courseId, String requestId) {
|
||||||
|
String key = getBookingLockKey(courseId);
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.setIfAbsent(key, requestId, LOCK_EXPIRE_TIME)
|
||||||
|
.doOnSuccess(acquired -> {
|
||||||
|
if (acquired) {
|
||||||
|
logger.debug("获取预约锁成功:courseId={}, requestId={}", courseId, requestId);
|
||||||
|
} else {
|
||||||
|
logger.debug("获取预约锁失败:courseId={}", courseId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放分布式锁
|
||||||
|
*
|
||||||
|
* @param courseId 课程ID
|
||||||
|
* @param requestId 请求ID(用于锁的唯一性校验)
|
||||||
|
* @return 是否释放成功
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> releaseLock(Long courseId, String requestId) {
|
||||||
|
String key = getBookingLockKey(courseId);
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.get(key)
|
||||||
|
.cast(String.class)
|
||||||
|
.flatMap(storedRequestId -> {
|
||||||
|
if (requestId.equals(storedRequestId)) {
|
||||||
|
return reactiveRedisTemplate.delete(key)
|
||||||
|
.map(deleted -> deleted > 0)
|
||||||
|
.doOnSuccess(result -> logger.debug("释放预约锁成功:courseId={}, requestId={}", courseId, requestId));
|
||||||
|
} else {
|
||||||
|
logger.warn("锁归属校验失败:courseId={}, expectedRequestId={}, actualRequestId={}",
|
||||||
|
courseId, requestId, storedRequestId);
|
||||||
|
return Mono.just(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取课程预约人数(缓存)
|
||||||
|
*/
|
||||||
|
public Mono<Integer> getBookingCount(Long courseId) {
|
||||||
|
String key = "booking_count:" + courseId;
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.get(key)
|
||||||
|
.map(obj -> (Integer) obj)
|
||||||
|
.defaultIfEmpty(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加课程预约人数
|
||||||
|
*/
|
||||||
|
public Mono<Long> incrementBookingCount(Long courseId) {
|
||||||
|
String key = "booking_count:" + courseId;
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.increment(key)
|
||||||
|
.doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少课程预约人数
|
||||||
|
*/
|
||||||
|
public Mono<Long> decrementBookingCount(Long courseId) {
|
||||||
|
String key = "booking_count:" + courseId;
|
||||||
|
return reactiveRedisTemplate.opsForValue()
|
||||||
|
.decrement(key)
|
||||||
|
.doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
|
||||||
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GroupCourseService implements IGroupCourseService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseService.class);
|
||||||
|
|
||||||
|
private final IGroupCourseRepository groupCourseRepository;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
||||||
|
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
|
public GroupCourseService(IGroupCourseRepository groupCourseRepository,
|
||||||
|
RedisUtil redisUtil,
|
||||||
|
ObjectMapper objectMapper){
|
||||||
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourse> findById(Long id) {
|
||||||
|
String cacheKey = CACHE_KEY_ID_PREFIX + id;
|
||||||
|
|
||||||
|
return redisUtil.get(cacheKey, String.class)
|
||||||
|
.flatMap(cachedJson -> {
|
||||||
|
if (cachedJson != null) {
|
||||||
|
try {
|
||||||
|
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
|
||||||
|
logger.info("缓存命中 - findById: id={}", id);
|
||||||
|
return Mono.just(groupCourse);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
|
||||||
|
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.switchIfEmpty(
|
||||||
|
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||||
|
.flatMap(groupCourse -> {
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(groupCourse);
|
||||||
|
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
||||||
|
.thenReturn(groupCourse)
|
||||||
|
.doOnSuccess(gc -> logger.debug("缓存已设置 - findById: id={}", id));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
|
||||||
|
return Mono.just(groupCourse);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findAll() {
|
||||||
|
return groupCourseRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<GroupCourse> findAll(boolean includeDeleted) {
|
||||||
|
if(includeDeleted){
|
||||||
|
return groupCourseRepository.findAll();
|
||||||
|
}else{
|
||||||
|
return groupCourseRepository.findByDeletedAtIsNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
||||||
|
|
||||||
|
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
|
||||||
|
|
||||||
|
return redisUtil.get(cacheKey, String.class)
|
||||||
|
.flatMap(cachedJson -> {
|
||||||
|
if (cachedJson != null) {
|
||||||
|
try {
|
||||||
|
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
|
||||||
|
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
|
||||||
|
logger.info("缓存命中 - findByPage: key={}", cacheKey);
|
||||||
|
return Mono.just(pageResponse);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||||
|
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.switchIfEmpty(
|
||||||
|
Mono.defer(() -> {
|
||||||
|
logger.debug("缓存未命中,查询数据库 - findByPage: key={}", cacheKey);
|
||||||
|
Mono<PageResponse<GroupCourse>> resultMono;
|
||||||
|
if (includeDeleted) {
|
||||||
|
resultMono = groupCourseRepository.findByPage(pageRequest);
|
||||||
|
} else {
|
||||||
|
resultMono = groupCourseRepository.findByPageAndNotDeleted(pageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultMono.flatMap(pageResponse -> {
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(pageResponse);
|
||||||
|
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
||||||
|
.thenReturn(pageResponse)
|
||||||
|
.doOnSuccess(pr -> logger.debug("缓存已设置 - findByPage: key={}", cacheKey));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("缓存设置失败 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||||
|
return Mono.just(pageResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: gym-groupCourse
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class GymGroupCourseApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -129,10 +129,7 @@
|
|||||||
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
|
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Redis响应式支持(会员卡模块需要) -->
|
<!-- Redis响应式支持(会员卡模块需要) -->
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
-3
@@ -12,9 +12,6 @@ public class SearchMemberDto {
|
|||||||
// 搜索字段 - 包括 会员号、昵称、手机号
|
// 搜索字段 - 包括 会员号、昵称、手机号
|
||||||
private String searchValue;
|
private String searchValue;
|
||||||
|
|
||||||
// 排序
|
|
||||||
private String filter;
|
|
||||||
|
|
||||||
// 页码
|
// 页码
|
||||||
private Integer pageNum = 1;
|
private Integer pageNum = 1;
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.member.dto;
|
package cn.novalon.gym.manage.member.dto;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.enums.GenderEnum;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,10 +19,10 @@ public class UpdateMemberInfoDto {
|
|||||||
private String nickname;
|
private String nickname;
|
||||||
|
|
||||||
// 性别
|
// 性别
|
||||||
private Integer gender;
|
private GenderEnum gender;
|
||||||
|
|
||||||
// 生日
|
// 生日
|
||||||
private Date birthday;
|
private LocalDate birthday;
|
||||||
|
|
||||||
// 头像
|
// 头像
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
|||||||
+3
@@ -33,6 +33,9 @@ public abstract class BaseEntity implements Persistable<Long> {
|
|||||||
@Column("updated_at")
|
@Column("updated_at")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column("deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
// 判断当前实体是否是新建的
|
// 判断当前实体是否是新建的
|
||||||
@Override
|
@Override
|
||||||
public boolean isNew() {
|
public boolean isNew() {
|
||||||
|
|||||||
+3
-6
@@ -1,13 +1,10 @@
|
|||||||
package cn.novalon.gym.manage.member.entity;
|
package cn.novalon.gym.manage.member.entity;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.*;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.springframework.data.relational.core.mapping.Column;
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
import org.springframework.data.relational.core.mapping.Table;
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
@@ -44,7 +41,7 @@ public class Member extends BaseEntity {
|
|||||||
|
|
||||||
//生日
|
//生日
|
||||||
@Column("birthday")
|
@Column("birthday")
|
||||||
private Date birthday;
|
private LocalDate birthday;
|
||||||
|
|
||||||
//地址
|
//地址
|
||||||
@Column("address")
|
@Column("address")
|
||||||
|
|||||||
-38
@@ -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;
|
|
||||||
}
|
|
||||||
+37
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -1,12 +1,18 @@
|
|||||||
package cn.novalon.gym.manage.member.es.entity;
|
package cn.novalon.gym.manage.member.es.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.elasticsearch.annotations.Document;
|
import org.springframework.data.elasticsearch.annotations.Document;
|
||||||
import org.springframework.data.elasticsearch.annotations.Field;
|
import org.springframework.data.elasticsearch.annotations.Field;
|
||||||
import org.springframework.data.elasticsearch.annotations.FieldType;
|
import org.springframework.data.elasticsearch.annotations.FieldType;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
@Document(indexName = "gym_members")
|
@Document(indexName = "gym_members")
|
||||||
public class MemberES {
|
public class MemberES {
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.member.es.repository;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.elasticsearch.annotations.Query;
|
||||||
import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository;
|
import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@@ -15,6 +16,6 @@ public interface MemberESRepository extends ReactiveElasticsearchRepository<Memb
|
|||||||
/**
|
/**
|
||||||
* 前台通用搜索:会员号(精确匹配) 或 昵称(模糊匹配) 或 手机号(精确匹配)并且 性别筛选(精确匹配)
|
* 前台通用搜索:会员号(精确匹配) 或 昵称(模糊匹配) 或 手机号(精确匹配)并且 性别筛选(精确匹配)
|
||||||
*/
|
*/
|
||||||
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContainingAndGender(
|
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContaining(
|
||||||
String memberNo, String phone, String nickname,String gender, Pageable pageable);
|
String memberNo, String phone, String nickname, Pageable pageable);
|
||||||
}
|
}
|
||||||
+41
-87
@@ -8,8 +8,11 @@ import cn.novalon.gym.manage.member.service.MemberService;
|
|||||||
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
||||||
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
||||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
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.util.AuthUtil;
|
||||||
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.math.NumberUtils;
|
import org.apache.commons.lang3.math.NumberUtils;
|
||||||
@@ -29,21 +32,15 @@ import reactor.core.publisher.Mono;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "会员管理", description = "会员信息管理、微信绑定、服务号关注等")
|
||||||
public class MemberHandler {
|
public class MemberHandler {
|
||||||
|
|
||||||
private final MemberService memberService;
|
private final MemberService memberService;
|
||||||
private final WechatAuthService wechatAuthService;
|
private final WechatAuthService wechatAuthService;
|
||||||
private final WechatOfficialService wechatOfficialService;
|
private final WechatOfficialService wechatOfficialService;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
|
||||||
private final WechatProperties wechatProperties;
|
|
||||||
private final AuthUtil authUtil;
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
/**
|
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
|
||||||
* 获取会员信息
|
|
||||||
*
|
|
||||||
* GET /api/member/info
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
|
public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
|
||||||
|
|
||||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -56,19 +53,7 @@ public class MemberHandler {
|
|||||||
.bodyValue(info));
|
.bodyValue(info));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
|
||||||
* 更新会员信息
|
|
||||||
*
|
|
||||||
* PUT /api/member/info
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
* Body: {
|
|
||||||
* "nickname": "新昵称",
|
|
||||||
* "gender": 1,
|
|
||||||
* "birthday": "2000-01-01",
|
|
||||||
* "avatar": "https://example.com/avatar.jpg",
|
|
||||||
* "address": "北京市朝阳区"
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> updateMemberInfo(ServerRequest request) {
|
public Mono<ServerResponse> updateMemberInfo(ServerRequest request) {
|
||||||
|
|
||||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -82,11 +67,7 @@ public class MemberHandler {
|
|||||||
.bodyValue(info));
|
.bodyValue(info));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号")
|
||||||
* 绑定手机号(微信小程序)
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
* POST /api/member/phone/bind?code=PHONE_CODE
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> bindPhone(ServerRequest request) {
|
public Mono<ServerResponse> bindPhone(ServerRequest request) {
|
||||||
|
|
||||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -103,12 +84,7 @@ public class MemberHandler {
|
|||||||
.bodyValue(success));
|
.bodyValue(success));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号")
|
||||||
* 查询服务号关注状态
|
|
||||||
*
|
|
||||||
* GET /api/member/subscribe/status
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) {
|
public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) {
|
||||||
|
|
||||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -123,14 +99,7 @@ public class MemberHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号")
|
||||||
* 管理员更新手机号
|
|
||||||
*
|
|
||||||
* POST /api/admin/member/123/phone
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
* Body: { "phone": "13800138000" }
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) {
|
public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) {
|
||||||
|
|
||||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -162,13 +131,7 @@ public class MemberHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息")
|
||||||
* 前台查看会员信息
|
|
||||||
*
|
|
||||||
* GET /api/admin/member/{id}
|
|
||||||
* header: { "Authorization": "xxx" }
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) {
|
public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) {
|
||||||
|
|
||||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -179,20 +142,24 @@ public class MemberHandler {
|
|||||||
|
|
||||||
log.info("前台查看会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
log.info("前台查看会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
||||||
|
|
||||||
// TODO 多表查询:会员信息、团课信息、会员卡信息
|
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()
|
return ServerResponse.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.bodyValue("成功");
|
.bodyValue(detail);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
|
||||||
* 前台编辑会员信息
|
|
||||||
*
|
|
||||||
* PUT /api/admin/member/{id}
|
|
||||||
* header: { "Authorization": "xxx" }
|
|
||||||
* Body:{"字段","值"}
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
|
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
|
||||||
|
|
||||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -201,42 +168,35 @@ public class MemberHandler {
|
|||||||
long memberId = NumberUtils.toLong(memberIdStr, 0L);
|
long memberId = NumberUtils.toLong(memberIdStr, 0L);
|
||||||
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
|
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
|
||||||
|
|
||||||
|
// TODO: 补充签到记录
|
||||||
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
||||||
|
|
||||||
// TODO 多表查询:会员信息、团课信息、会员卡信息
|
return request.bodyToMono(UpdateMemberInfoDto.class)
|
||||||
|
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
|
||||||
return ServerResponse.ok()
|
.flatMap(detail -> ServerResponse.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.bodyValue("成功");
|
.bodyValue(detail));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
|
||||||
* 前台搜索会员列表
|
|
||||||
*
|
|
||||||
* GET /api/admin/members?searchValue=手机号/姓名/会员号&filter=男/女&pageNum=1&pageSize=10
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> searchMembers(ServerRequest request) {
|
public Mono<ServerResponse> searchMembers(ServerRequest request) {
|
||||||
|
|
||||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||||
|
|
||||||
String keyword = request.queryParam("searchValue").orElse(null);
|
String keyword = request.queryParam("searchValue").orElse(null);
|
||||||
String filter = request.queryParam("filter").orElse(null);
|
Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||||
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
Integer pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
|
||||||
|
|
||||||
log.info("前台搜索会员列表, adminId: {}, keyword: {}, filter: {}, pageNum: {}, pageSize: {}",
|
log.info("前台搜索会员列表, adminId: {}, keyword: {},pageNum: {}, pageSize: {}",
|
||||||
adminId, keyword, filter, pageNum, pageSize);
|
adminId, keyword, pageNum, pageSize);
|
||||||
|
|
||||||
return memberService.searchMember(new SearchMemberDto(keyword, filter, pageNum, pageSize))
|
return memberService.searchMember(new SearchMemberDto(keyword, pageNum, pageSize))
|
||||||
.map(member -> {
|
.map(member -> {
|
||||||
// 解密手机号
|
// 解密手机号
|
||||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||||
try {
|
try {
|
||||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
|
|
||||||
member.setPhone(decryptedPhone);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||||
member.setPhone(null);
|
member.setPhone(null);
|
||||||
@@ -249,12 +209,7 @@ public class MemberHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
@Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表")
|
||||||
* 前台查看会员列表
|
|
||||||
*
|
|
||||||
* GET /api/admin/members/all?pageNum=1&pageSize=10
|
|
||||||
* header: { "Authorization": "Bearer xxx" }
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> getAllMembers(ServerRequest request) {
|
public Mono<ServerResponse> getAllMembers(ServerRequest request) {
|
||||||
|
|
||||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||||
@@ -263,16 +218,15 @@ public class MemberHandler {
|
|||||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||||
|
|
||||||
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
|
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
|
||||||
|
// TODO: 补充签到记录
|
||||||
|
|
||||||
return memberService.findAll(pageNum, pageSize)
|
return memberService.findAll(pageNum, pageSize)
|
||||||
.map(member -> {
|
.map(member -> {
|
||||||
// 解密手机号
|
// 解密手机号
|
||||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||||
try {
|
try {
|
||||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
|
|
||||||
member.setPhone(decryptedPhone);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||||
member.setPhone(null);
|
member.setPhone(null);
|
||||||
|
|||||||
+6
-17
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.member.handler;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
||||||
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -20,20 +22,13 @@ import reactor.core.publisher.Mono;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "微信认证", description = "微信小程序登录、公众号回调等")
|
||||||
public class WechatAuthHandler {
|
public class WechatAuthHandler {
|
||||||
|
|
||||||
private final WechatAuthService wechatAuthService;
|
private final WechatAuthService wechatAuthService;
|
||||||
private final WechatOfficialEventHandler wechatOfficialEventHandler;
|
private final WechatOfficialEventHandler wechatOfficialEventHandler;
|
||||||
|
|
||||||
/**
|
@Operation(summary = "微信小程序登录", description = "通过微信小程序code获取session_key,完成会员登录或注册")
|
||||||
* 小程序更新
|
|
||||||
*
|
|
||||||
* POST /api/member/auth/miniapp/login
|
|
||||||
* Body: {"code": "wx_login_code"}
|
|
||||||
*
|
|
||||||
* @param request ServerRequest
|
|
||||||
* @return Mono<ServerResponse> 登录响应
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> miniappLogin(ServerRequest request) {
|
public Mono<ServerResponse> miniappLogin(ServerRequest request) {
|
||||||
log.info("收到小程序登录请求");
|
log.info("收到小程序登录请求");
|
||||||
|
|
||||||
@@ -50,18 +45,12 @@ public class WechatAuthHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "微信公众号回调", description = "处理微信公众号事件(关注、取消关注等)")
|
||||||
* 公众号回调
|
|
||||||
*
|
|
||||||
* POST /api/member/auth/mp/callback
|
|
||||||
* Body: <xml><Event>subscribe</Event><FromUserName>openid</FromUserName></xml>
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public Mono<ServerResponse> mpCallback(ServerRequest request) {
|
public Mono<ServerResponse> mpCallback(ServerRequest request) {
|
||||||
return wechatOfficialEventHandler.handleEvent(request);
|
return wechatOfficialEventHandler.handleEvent(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证微信公众号签名
|
@Operation(summary = "验证微信公众号签名", description = "微信公众号服务器验证,返回echostr")
|
||||||
public Mono<ServerResponse> verifyMpSignature(ServerRequest request) {
|
public Mono<ServerResponse> verifyMpSignature(ServerRequest request) {
|
||||||
return wechatOfficialEventHandler.verifySignature(request);
|
return wechatOfficialEventHandler.verifySignature(request);
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -40,9 +40,6 @@ public class WechatOfficialEventHandler {
|
|||||||
.flatMap(xmlBody -> {
|
.flatMap(xmlBody -> {
|
||||||
log.info("收到微信公众号事件 {}", xmlBody);
|
log.info("收到微信公众号事件 {}", xmlBody);
|
||||||
|
|
||||||
// TODO: 将XML解析为WechatOfficialEventDto
|
|
||||||
// 目前简化处理直接获取openId和event
|
|
||||||
|
|
||||||
String openId = extractOpenId(xmlBody);
|
String openId = extractOpenId(xmlBody);
|
||||||
String event = extractEvent(xmlBody);
|
String event = extractEvent(xmlBody);
|
||||||
|
|
||||||
|
|||||||
+40
-1
@@ -1,7 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.member.repository;
|
package cn.novalon.gym.manage.member.repository;
|
||||||
|
|
||||||
import cn.novalon.gym.manage.member.entity.Member;
|
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.domain.Pageable;
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@@ -30,7 +32,44 @@ public interface IMemberRepository extends R2dbcRepository<Member, Long> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 分页查询所有会员
|
* 分页查询所有会员
|
||||||
* 方法名 findAllBy 是 Spring Data 的约定,表示按条件查询所有
|
|
||||||
*/
|
*/
|
||||||
Flux<Member> findAllBy(Pageable pageable);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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.dto.UpdateMemberInfoDto;
|
||||||
import cn.novalon.gym.manage.member.entity.Member;
|
import cn.novalon.gym.manage.member.entity.Member;
|
||||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
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 cn.novalon.gym.manage.member.vo.MemberInfoVO;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -58,4 +59,21 @@ public interface MemberService {
|
|||||||
* @return 所有会员信息
|
* @return 所有会员信息
|
||||||
*/
|
*/
|
||||||
Flux<Member> findAll(Integer pageNum, Integer pageSize);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-5
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.member.service.impl;
|
|||||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
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;
|
||||||
@@ -16,17 +18,35 @@ import java.time.LocalDateTime;
|
|||||||
* @author 付嘉
|
* @author 付嘉
|
||||||
* @date 2026-05-27
|
* @date 2026-05-27
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository) {
|
private static final String MEMBER_CARD_RECORD_CACHE_PREFIX = "member:card:record:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
|
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository, RedisUtil redisUtil) {
|
||||||
this.memberCardRecordRepository = memberCardRecordRepository;
|
this.memberCardRecordRepository = memberCardRecordRepository;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCardRecord> findById(Long recordId) {
|
public Mono<MemberCardRecord> findById(Long recordId) {
|
||||||
return memberCardRecordRepository.findById(recordId);
|
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||||
|
Object cached = redisUtil.get(cacheKey);
|
||||||
|
if (cached != null && cached instanceof MemberCardRecord) {
|
||||||
|
log.debug("从缓存获取会员卡记录, recordId: {}", recordId);
|
||||||
|
return Mono.just((MemberCardRecord) cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberCardRecordRepository.findById(recordId)
|
||||||
|
.doOnSuccess(record -> {
|
||||||
|
if (record != null) {
|
||||||
|
redisUtil.setWithExpire(cacheKey, record, CACHE_EXPIRE_SECONDS);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -53,17 +73,32 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Integer> deductUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
public Mono<Integer> deductUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
||||||
return memberCardRecordRepository.deductUsage(recordId, deductTimes, deductAmount);
|
return memberCardRecordRepository.deductUsage(recordId, deductTimes, deductAmount)
|
||||||
|
.doOnSuccess(updated -> {
|
||||||
|
if (updated > 0) {
|
||||||
|
clearRecordCache(recordId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Integer> renewCard(Long recordId, Integer addTimes, Double addAmount, LocalDateTime newExpireTime) {
|
public Mono<Integer> renewCard(Long recordId, Integer addTimes, Double addAmount, LocalDateTime newExpireTime) {
|
||||||
return memberCardRecordRepository.renewCard(recordId, addTimes, addAmount, newExpireTime);
|
return memberCardRecordRepository.renewCard(recordId, addTimes, addAmount, newExpireTime)
|
||||||
|
.doOnSuccess(updated -> {
|
||||||
|
if (updated > 0) {
|
||||||
|
clearRecordCache(recordId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Integer> updateStatus(Long recordId, String status) {
|
public Mono<Integer> updateStatus(Long recordId, String status) {
|
||||||
return memberCardRecordRepository.updateStatus(recordId, status);
|
return memberCardRecordRepository.updateStatus(recordId, status)
|
||||||
|
.doOnSuccess(updated -> {
|
||||||
|
if (updated > 0) {
|
||||||
|
clearRecordCache(recordId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -80,4 +115,10 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
public Flux<MemberCardRecord> findExpiredCards() {
|
public Flux<MemberCardRecord> findExpiredCards() {
|
||||||
return memberCardRecordRepository.findExpiredCards();
|
return memberCardRecordRepository.findExpiredCards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearRecordCache(Long recordId) {
|
||||||
|
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||||
|
redisUtil.delete(cacheKey);
|
||||||
|
log.debug("清除会员卡记录缓存, recordId: {}", recordId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -15,6 +15,7 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
|||||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
|
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -39,6 +40,10 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
private final DistributedLockService distributedLockService;
|
private final DistributedLockService distributedLockService;
|
||||||
private final ExpirationReminderService expirationReminderService;
|
private final ExpirationReminderService expirationReminderService;
|
||||||
private final RefundSagaHandler refundSagaHandler;
|
private final RefundSagaHandler refundSagaHandler;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
|
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
||||||
MemberCardRecordRepository recordRepository,
|
MemberCardRecordRepository recordRepository,
|
||||||
@@ -46,7 +51,8 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
MemberCardStateMachine stateMachine,
|
MemberCardStateMachine stateMachine,
|
||||||
DistributedLockService distributedLockService,
|
DistributedLockService distributedLockService,
|
||||||
ExpirationReminderService expirationReminderService,
|
ExpirationReminderService expirationReminderService,
|
||||||
RefundSagaHandler refundSagaHandler) {
|
RefundSagaHandler refundSagaHandler,
|
||||||
|
RedisUtil redisUtil) {
|
||||||
this.memberCardRepository = memberCardRepository;
|
this.memberCardRepository = memberCardRepository;
|
||||||
this.recordRepository = recordRepository;
|
this.recordRepository = recordRepository;
|
||||||
this.transactionService = transactionService;
|
this.transactionService = transactionService;
|
||||||
@@ -54,11 +60,24 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
this.distributedLockService = distributedLockService;
|
this.distributedLockService = distributedLockService;
|
||||||
this.expirationReminderService = expirationReminderService;
|
this.expirationReminderService = expirationReminderService;
|
||||||
this.refundSagaHandler = refundSagaHandler;
|
this.refundSagaHandler = refundSagaHandler;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId) {
|
public Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId) {
|
||||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId);
|
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||||
|
Object cached = redisUtil.get(cacheKey);
|
||||||
|
if (cached != null && cached instanceof MemberCard) {
|
||||||
|
log.debug("从缓存获取会员卡信息, memberCardId: {}", memberCardId);
|
||||||
|
return Mono.just((MemberCard) cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId)
|
||||||
|
.doOnSuccess(card -> {
|
||||||
|
if (card != null) {
|
||||||
|
redisUtil.setWithExpire(cacheKey, card, CACHE_EXPIRE_SECONDS);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -95,7 +114,12 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCard> save(MemberCard entity) {
|
public Mono<MemberCard> save(MemberCard entity) {
|
||||||
return memberCardRepository.save(entity);
|
return memberCardRepository.save(entity)
|
||||||
|
.doOnSuccess(saved -> {
|
||||||
|
if (saved.getMemberCardId() != null) {
|
||||||
|
clearCardCache(saved.getMemberCardId());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -329,4 +353,10 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
return transactionService.createTransaction(transaction);
|
return transactionService.createTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearCardCache(Long memberCardId) {
|
||||||
|
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||||
|
redisUtil.delete(cacheKey);
|
||||||
|
log.debug("清除会员卡缓存, memberCardId: {}", memberCardId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-21
@@ -4,28 +4,35 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
|
|||||||
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
||||||
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
||||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
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.SearchMemberDto;
|
||||||
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
|
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
|
||||||
import cn.novalon.gym.manage.member.entity.Member;
|
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.entity.MemberES;
|
||||||
import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
|
import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
|
||||||
import cn.novalon.gym.manage.member.repository.IMemberRepository;
|
import cn.novalon.gym.manage.member.repository.IMemberRepository;
|
||||||
import cn.novalon.gym.manage.member.service.MemberService;
|
import cn.novalon.gym.manage.member.service.MemberService;
|
||||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
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.util.EsSyncUtils;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
|
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 cn.novalon.gym.manage.member.vo.MemberInfoVO;
|
||||||
import jakarta.annotation.PostConstruct;
|
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.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会员服务实现
|
* 会员服务实现
|
||||||
@@ -41,10 +48,14 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
private final IMemberRepository memberRepository;
|
private final IMemberRepository memberRepository;
|
||||||
private final MemberESRepository memberESRepository;
|
private final MemberESRepository memberESRepository;
|
||||||
private final EsSyncUtils esSyncUtils;
|
private final EsSyncUtils esSyncUtils;
|
||||||
private final WechatProperties wechatProperties;
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||||
|
|
||||||
|
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||||
|
private static final String MEMBER_DETAIL_CACHE_PREFIX = "member:detail:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||||
@@ -52,12 +63,23 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberInfoVO> getMemberInfo(Long memberId) {
|
public Mono<MemberInfoVO> getMemberInfo(Long memberId) {
|
||||||
|
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||||
|
|
||||||
|
return redisUtil.get(cacheKey, MemberInfoVO.class)
|
||||||
|
.flatMap(cached -> {
|
||||||
|
if (cached != null) {
|
||||||
|
log.debug("从缓存获取会员信息, memberId: {}", memberId);
|
||||||
|
return Mono.just(cached);
|
||||||
|
}
|
||||||
return memberRepository.findById(memberId)
|
return memberRepository.findById(memberId)
|
||||||
.map(this::buildMemberInfoResponse)
|
.map(this::buildMemberInfoResponse)
|
||||||
|
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||||
|
.then(Mono.just(vo)))
|
||||||
.switchIfEmpty(Mono.error(() -> {
|
.switchIfEmpty(Mono.error(() -> {
|
||||||
log.error("会员不存在: memberId={}", memberId);
|
log.error("会员不存在: memberId={}", memberId);
|
||||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||||
}));
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -67,10 +89,10 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
return memberRepository.findById(memberId)
|
return memberRepository.findById(memberId)
|
||||||
.flatMap(member -> {
|
.flatMap(member -> {
|
||||||
if (updateDto.getNickname() != null) {
|
if (updateDto.getNickname() != null) {
|
||||||
member.setNickname(updateDto.getNickname());
|
member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname()));
|
||||||
}
|
}
|
||||||
if (updateDto.getGender() != null) {
|
if (updateDto.getGender() != null) {
|
||||||
member.setGender(updateDto.getGender());
|
member.setGender(updateDto.getGender().getCode());
|
||||||
}
|
}
|
||||||
if (updateDto.getBirthday() != null) {
|
if (updateDto.getBirthday() != null) {
|
||||||
member.setBirthday(updateDto.getBirthday());
|
member.setBirthday(updateDto.getBirthday());
|
||||||
@@ -79,12 +101,16 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
member.setAvatar(updateDto.getAvatar());
|
member.setAvatar(updateDto.getAvatar());
|
||||||
}
|
}
|
||||||
if (updateDto.getAddress() != null) {
|
if (updateDto.getAddress() != null) {
|
||||||
member.setAddress(updateDto.getAddress());
|
member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return memberRepository.save(member);
|
return memberRepository.save(member);
|
||||||
})
|
})
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(savedMember -> {
|
||||||
|
memberSyncer.sync(savedMember);
|
||||||
|
return clearMemberCache(memberId)
|
||||||
|
.then(Mono.just(savedMember));
|
||||||
|
})
|
||||||
.map(savedMember -> {
|
.map(savedMember -> {
|
||||||
log.info("会员信息更新成功, memberId: {}", savedMember.getId());
|
log.info("会员信息更新成功, memberId: {}", savedMember.getId());
|
||||||
return buildMemberInfoResponse(savedMember);
|
return buildMemberInfoResponse(savedMember);
|
||||||
@@ -99,11 +125,14 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
String phone = member.getPhone();
|
String phone = member.getPhone();
|
||||||
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
|
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
|
||||||
|
|
||||||
|
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
|
||||||
|
|
||||||
return MemberInfoVO.builder()
|
return MemberInfoVO.builder()
|
||||||
.id(member.getId())
|
.id(member.getId())
|
||||||
.nickname(member.getNickname())
|
.nickname(member.getNickname())
|
||||||
.phone(maskedPhone)
|
.phone(maskedPhone)
|
||||||
.gender(member.getGender())
|
.gender(genderEnum)
|
||||||
|
.genderDesc(genderEnum.getDesc())
|
||||||
.birthday(member.getBirthday())
|
.birthday(member.getBirthday())
|
||||||
.avatar(member.getAvatar())
|
.avatar(member.getAvatar())
|
||||||
.hasPhone(phone != null)
|
.hasPhone(phone != null)
|
||||||
@@ -117,9 +146,7 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
|
|
||||||
String encryptedPhone;
|
String encryptedPhone;
|
||||||
try {
|
try {
|
||||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
encryptedPhone = AesUtil.encrypt(phone);
|
||||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
|
||||||
encryptedPhone = AesUtil.encrypt(phone, secretKey, iv);
|
|
||||||
log.info("手机号加密成功");
|
log.info("手机号加密成功");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号加密失败", e);
|
log.error("手机号加密失败", e);
|
||||||
@@ -145,9 +172,8 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<MemberES> searchMember(SearchMemberDto searchMemberDto) {
|
public Flux<MemberES> searchMember(SearchMemberDto searchMemberDto) {
|
||||||
log.info("搜索会员, searchValue: {}, filter: {}, pageNum: {}, pageSize: {}",
|
log.info("搜索会员, searchValue: {}, pageNum: {}, pageSize: {}",
|
||||||
searchMemberDto.getSearchValue(),
|
searchMemberDto.getSearchValue(),
|
||||||
searchMemberDto.getFilter(),
|
|
||||||
searchMemberDto.getPageNum(),
|
searchMemberDto.getPageNum(),
|
||||||
searchMemberDto.getPageSize());
|
searchMemberDto.getPageSize());
|
||||||
|
|
||||||
@@ -155,22 +181,23 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
|
|
||||||
if (searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")) {
|
if (searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")) {
|
||||||
log.debug("搜索值为手机号格式,进行加密处理");
|
log.debug("搜索值为手机号格式,进行加密处理");
|
||||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
searchValue = AesUtil.encrypt(searchValue);
|
||||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
|
||||||
searchValue = AesUtil.encrypt(searchValue,secretKey,iv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Pageable pageable = PageRequest.of(
|
Pageable pageable = PageRequest.of(
|
||||||
searchMemberDto.getPageNum() - 1,
|
searchMemberDto.getPageNum() - 1,
|
||||||
searchMemberDto.getPageSize(),
|
searchMemberDto.getPageSize()
|
||||||
Sort.by(Sort.Direction.DESC, "update_at")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return memberESRepository.findByMemberNoOrPhoneOrNicknameContainingAndGender(
|
if (searchValue == null) {
|
||||||
|
log.warn("搜索值为空,返回空结果");
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberESRepository.findByMemberNoOrPhoneOrNicknameContaining(
|
||||||
searchValue,
|
searchValue,
|
||||||
searchValue,
|
searchValue,
|
||||||
searchValue,
|
searchValue,
|
||||||
searchMemberDto.getFilter() ,
|
|
||||||
pageable
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -187,6 +214,99 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
return memberRepository.findAllBy(pageable);
|
return memberRepository.findAllBy(pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<MemberDetailVO> getMemberDetail(Long memberId) {
|
||||||
|
log.info("查询会员详情, memberId: {}", memberId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||||
|
.then(Mono.just(vo)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 -> {
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
.flatMap(savedMember -> {
|
||||||
|
memberSyncer.sync(savedMember);
|
||||||
|
return clearMemberCache(memberId)
|
||||||
|
.then(Mono.just(true));
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("编辑会员信息失败, memberId: {}, error: {}", memberId, e.getMessage(), e);
|
||||||
|
return Mono.just(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<Boolean> updateMemberPhone(Long memberId, String encryptedPhone) {
|
private Mono<Boolean> updateMemberPhone(Long memberId, String encryptedPhone) {
|
||||||
return memberRepository.findById(memberId)
|
return memberRepository.findById(memberId)
|
||||||
.flatMap(member -> {
|
.flatMap(member -> {
|
||||||
@@ -194,7 +314,11 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(savedMember -> {
|
||||||
|
memberSyncer.sync(savedMember);
|
||||||
|
return clearMemberCache(memberId)
|
||||||
|
.then(Mono.just(savedMember));
|
||||||
|
})
|
||||||
.map(savedMember -> {
|
.map(savedMember -> {
|
||||||
log.info("手机号录入成功, memberId: {}", savedMember.getId());
|
log.info("手机号录入成功, memberId: {}", savedMember.getId());
|
||||||
return true;
|
return true;
|
||||||
@@ -205,4 +329,12 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<Long> clearMemberCache(Long memberId) {
|
||||||
|
String infoCacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||||
|
String detailCacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
||||||
|
return redisUtil.delete(infoCacheKey)
|
||||||
|
.then(redisUtil.delete(detailCacheKey))
|
||||||
|
.doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+52
-15
@@ -1,9 +1,11 @@
|
|||||||
package cn.novalon.gym.manage.member.service.impl;
|
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.entity.RefundApplication;
|
||||||
import cn.novalon.gym.manage.member.enums.RefundStatus;
|
import cn.novalon.gym.manage.member.enums.RefundStatus;
|
||||||
import cn.novalon.gym.manage.member.repository.RefundApplicationRepository;
|
import cn.novalon.gym.manage.member.repository.RefundApplicationRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IRefundApplicationService;
|
import cn.novalon.gym.manage.member.service.IRefundApplicationService;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -21,9 +23,14 @@ import java.time.LocalDateTime;
|
|||||||
public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
||||||
|
|
||||||
private final RefundApplicationRepository refundApplicationRepository;
|
private final RefundApplicationRepository refundApplicationRepository;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
public RefundApplicationServiceImpl(RefundApplicationRepository refundApplicationRepository) {
|
private static final String REFUND_APPLICATION_CACHE_PREFIX = "member:refund:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
|
public RefundApplicationServiceImpl(RefundApplicationRepository refundApplicationRepository, RedisUtil redisUtil) {
|
||||||
this.refundApplicationRepository = refundApplicationRepository;
|
this.refundApplicationRepository = refundApplicationRepository;
|
||||||
|
this.redisUtil = redisUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -38,14 +45,16 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
|||||||
.then(Mono.defer(() -> {
|
.then(Mono.defer(() -> {
|
||||||
RefundApplication application = RefundApplication.builder()
|
RefundApplication application = RefundApplication.builder()
|
||||||
.recordId(recordId)
|
.recordId(recordId)
|
||||||
.reason(reason)
|
.reason(HtmlEscapeUtil.escape(reason))
|
||||||
.status(RefundStatus.PENDING)
|
.status(RefundStatus.PENDING)
|
||||||
.applyTime(LocalDateTime.now())
|
.applyTime(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return refundApplicationRepository.save(application)
|
return refundApplicationRepository.save(application)
|
||||||
.doOnSuccess(app -> log.info("创建退款申请成功: applicationId={}, recordId={}",
|
.doOnSuccess(app -> {
|
||||||
app.getId(), recordId));
|
log.info("创建退款申请成功: applicationId={}, recordId={}", app.getId(), recordId);
|
||||||
|
clearRefundCache(recordId);
|
||||||
|
});
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,14 +63,19 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
|||||||
return refundApplicationRepository.findById(applicationId)
|
return refundApplicationRepository.findById(applicationId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
||||||
.flatMap(application -> {
|
.flatMap(application -> {
|
||||||
if (!"PENDING".equals(application.getStatus())) {
|
if (application.getStatus() != RefundStatus.PENDING) {
|
||||||
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
|
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)
|
.flatMap(updatedRows -> {
|
||||||
.doOnSuccess(app -> log.info("批准退款申请成功: applicationId={}, auditorId={}",
|
if (updatedRows == 0) {
|
||||||
applicationId, auditorId));
|
return Mono.error(new RuntimeException("批准退款申请失败"));
|
||||||
|
}
|
||||||
|
clearRefundCache(application.getRecordId());
|
||||||
|
log.info("批准退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId);
|
||||||
|
return refundApplicationRepository.findById(applicationId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +84,42 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
|||||||
return refundApplicationRepository.findById(applicationId)
|
return refundApplicationRepository.findById(applicationId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
||||||
.flatMap(application -> {
|
.flatMap(application -> {
|
||||||
if (!"PENDING".equals(application.getStatus())) {
|
if (application.getStatus() != RefundStatus.PENDING) {
|
||||||
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
|
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)
|
.flatMap(updatedRows -> {
|
||||||
.doOnSuccess(app -> log.info("拒绝退款申请成功: applicationId={}, auditorId={}",
|
if (updatedRows == 0) {
|
||||||
applicationId, auditorId));
|
return Mono.error(new RuntimeException("拒绝退款申请失败"));
|
||||||
|
}
|
||||||
|
clearRefundCache(application.getRecordId());
|
||||||
|
log.info("拒绝退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId);
|
||||||
|
return refundApplicationRepository.findById(applicationId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<RefundApplication> findByRecordId(Long recordId) {
|
public Mono<RefundApplication> findByRecordId(Long recordId) {
|
||||||
return refundApplicationRepository.findByRecordId(recordId);
|
String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId;
|
||||||
|
Object cached = redisUtil.get(cacheKey);
|
||||||
|
if (cached != null && cached instanceof RefundApplication) {
|
||||||
|
log.debug("从缓存获取退款申请, recordId: {}", recordId);
|
||||||
|
return Mono.just((RefundApplication) cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return refundApplicationRepository.findByRecordId(recordId)
|
||||||
|
.doOnSuccess(application -> {
|
||||||
|
if (application != null) {
|
||||||
|
redisUtil.setWithExpire(cacheKey, application, CACHE_EXPIRE_SECONDS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearRefundCache(Long recordId) {
|
||||||
|
String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId;
|
||||||
|
redisUtil.delete(cacheKey);
|
||||||
|
log.debug("清除退款申请缓存, recordId: {}", recordId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.common.exception.ErrorCode;
|
|||||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
import cn.novalon.gym.manage.common.exception.SystemException;
|
||||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||||
import cn.novalon.gym.manage.member.service.WechatApiService;
|
import cn.novalon.gym.manage.member.service.WechatApiService;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -30,6 +31,10 @@ import java.util.Map;
|
|||||||
public class WechatApiServiceImpl implements WechatApiService {
|
public class WechatApiServiceImpl implements WechatApiService {
|
||||||
|
|
||||||
private final WechatProperties wechatProperties;
|
private final WechatProperties wechatProperties;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:";
|
||||||
|
private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000; // 比官方过期时间短100秒
|
||||||
|
|
||||||
private final WebClient webClient = WebClient.builder()
|
private final WebClient webClient = WebClient.builder()
|
||||||
.baseUrl("https://api.weixin.qq.com")
|
.baseUrl("https://api.weixin.qq.com")
|
||||||
@@ -147,6 +152,15 @@ public class WechatApiServiceImpl implements WechatApiService {
|
|||||||
public Mono<String> getAccessToken(String appType) {
|
public Mono<String> getAccessToken(String appType) {
|
||||||
log.debug("获取access_token, appType: {}", appType);
|
log.debug("获取access_token, appType: {}", appType);
|
||||||
|
|
||||||
|
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType;
|
||||||
|
|
||||||
|
return redisUtil.get(cacheKey, String.class)
|
||||||
|
.flatMap(cachedToken -> {
|
||||||
|
if (cachedToken != null) {
|
||||||
|
log.debug("从缓存获取access_token, appType: {}", appType);
|
||||||
|
return Mono.just(cachedToken);
|
||||||
|
}
|
||||||
|
|
||||||
String appId, appSecret;
|
String appId, appSecret;
|
||||||
if ("miniapp".equals(appType)) {
|
if ("miniapp".equals(appType)) {
|
||||||
appId = wechatProperties.getMiniapp().getAppId();
|
appId = wechatProperties.getMiniapp().getAppId();
|
||||||
@@ -165,18 +179,20 @@ public class WechatApiServiceImpl implements WechatApiService {
|
|||||||
.build())
|
.build())
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.map(response -> {
|
.flatMap(response -> {
|
||||||
if (response.containsKey("access_token")) {
|
if (response.containsKey("access_token")) {
|
||||||
String accessToken = (String) response.get("access_token");
|
String accessToken = (String) response.get("access_token");
|
||||||
Integer expiresIn = (Integer) response.get("expires_in");
|
Integer expiresIn = (Integer) response.get("expires_in");
|
||||||
log.info("获取access_token成功, expires_in: {}s", expiresIn);
|
log.info("获取access_token成功, expires_in: {}s", expiresIn);
|
||||||
return accessToken;
|
return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
.then(Mono.just(accessToken));
|
||||||
} else {
|
} else {
|
||||||
String errmsg = (String) response.get("errmsg");
|
String errmsg = (String) response.get("errmsg");
|
||||||
log.error("获取access_token失败: {}", errmsg);
|
log.error("获取access_token失败: {}", errmsg);
|
||||||
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取access_token失败: " + errmsg);
|
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取access_token失败: " + errmsg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+33
-14
@@ -4,7 +4,6 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
|
|||||||
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
||||||
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
||||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
import cn.novalon.gym.manage.common.exception.SystemException;
|
||||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
|
||||||
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
||||||
import cn.novalon.gym.manage.member.entity.Member;
|
import cn.novalon.gym.manage.member.entity.Member;
|
||||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||||
@@ -15,6 +14,7 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
|
|||||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
import cn.novalon.gym.manage.member.util.AesUtil;
|
||||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||||
import cn.novalon.gym.manage.member.util.MemberNoGenerator;
|
import cn.novalon.gym.manage.member.util.MemberNoGenerator;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
||||||
import cn.novalon.gym.manage.member.vo.WechatLoginVO;
|
import cn.novalon.gym.manage.member.vo.WechatLoginVO;
|
||||||
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||||
@@ -42,14 +42,17 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
|
|
||||||
private final WechatApiService wechatApiService;
|
private final WechatApiService wechatApiService;
|
||||||
private final IMemberRepository memberRepository;
|
private final IMemberRepository memberRepository;
|
||||||
private final WechatProperties wechatProperties;
|
|
||||||
private final WechatPhoneUtil wechatPhoneUtil;
|
private final WechatPhoneUtil wechatPhoneUtil;
|
||||||
private final MemberESRepository memberESRepository;
|
private final MemberESRepository memberESRepository;
|
||||||
private final EsSyncUtils esSyncUtils;
|
private final EsSyncUtils esSyncUtils;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||||
|
|
||||||
|
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||||
@@ -79,7 +82,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.flatMap(savedMember -> {
|
.flatMap(savedMember -> {
|
||||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||||
return Mono.just(response);
|
return Mono.just(response);
|
||||||
@@ -89,7 +95,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.flatMap(savedMember -> {
|
.flatMap(savedMember -> {
|
||||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||||
return Mono.just(response);
|
return Mono.just(response);
|
||||||
@@ -105,7 +114,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.flatMap(savedMember -> {
|
.flatMap(savedMember -> {
|
||||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||||
return Mono.just(response);
|
return Mono.just(response);
|
||||||
@@ -124,7 +136,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.flatMap(savedMember -> {
|
.flatMap(savedMember -> {
|
||||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||||
return Mono.just(response);
|
return Mono.just(response);
|
||||||
@@ -185,7 +200,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
member.setPhone(encryptedPhone);
|
member.setPhone(encryptedPhone);
|
||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.map(savedMember -> {
|
.map(savedMember -> {
|
||||||
log.info("更新会员手机号成功, memberId: {}", savedMember.getId());
|
log.info("更新会员手机号成功, memberId: {}", savedMember.getId());
|
||||||
return true;
|
return true;
|
||||||
@@ -197,12 +215,15 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearMemberCache(Long memberId) {
|
||||||
|
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||||
|
redisUtil.delete(cacheKey);
|
||||||
|
log.debug("清除会员缓存, memberId: {}", memberId);
|
||||||
|
}
|
||||||
|
|
||||||
private String encryptPhone(String phoneNumber) {
|
private String encryptPhone(String phoneNumber) {
|
||||||
try {
|
try {
|
||||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
String encryptedPhone = AesUtil.encrypt(phoneNumber);
|
||||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
|
||||||
|
|
||||||
String encryptedPhone = AesUtil.encrypt(phoneNumber, secretKey, iv);
|
|
||||||
|
|
||||||
log.debug("手机号加密成功");
|
log.debug("手机号加密成功");
|
||||||
return encryptedPhone;
|
return encryptedPhone;
|
||||||
@@ -214,10 +235,8 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
|||||||
|
|
||||||
public String decryptPhone(String encryptedPhone) {
|
public String decryptPhone(String encryptedPhone) {
|
||||||
try {
|
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("手机号解密成功");
|
log.debug("手机号解密成功");
|
||||||
return phoneNumber;
|
return phoneNumber;
|
||||||
|
|||||||
+55
-14
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.member.service.impl;
|
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.config.WechatProperties;
|
||||||
import cn.novalon.gym.manage.member.entity.Member;
|
import cn.novalon.gym.manage.member.entity.Member;
|
||||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||||
@@ -7,6 +8,7 @@ import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
|
|||||||
import cn.novalon.gym.manage.member.repository.IMemberRepository;
|
import cn.novalon.gym.manage.member.repository.IMemberRepository;
|
||||||
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
||||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||||
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.member.vo.WechatUserInfoVO;
|
import cn.novalon.gym.manage.member.vo.WechatUserInfoVO;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -40,11 +42,16 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
private final MemberESRepository memberESRepository;
|
private final MemberESRepository memberESRepository;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
private final ObjectMapper objectMapper = new ObjectMapper()
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
private final EsSyncUtils esSyncUtils;
|
private final EsSyncUtils esSyncUtils;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:";
|
||||||
|
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||||
|
private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000;
|
||||||
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||||
@@ -74,24 +81,30 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
existingMember.setOfficialOpenId(openId);
|
existingMember.setOfficialOpenId(openId);
|
||||||
|
|
||||||
if (existingMember.getNickname() == null || existingMember.getNickname().isEmpty()) {
|
if (existingMember.getNickname() == null || existingMember.getNickname().isEmpty()) {
|
||||||
existingMember.setNickname(userInfo.getNickname());
|
existingMember.setNickname(HtmlEscapeUtil.escape(userInfo.getNickname()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) {
|
if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) {
|
||||||
existingMember.setAvatar(userInfo.getHeadimgurl());
|
existingMember.setAvatar(HtmlEscapeUtil.escape(userInfo.getHeadimgurl()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return memberRepository.save(existingMember)
|
return memberRepository.save(existingMember)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
return clearMemberCache(saved.getId())
|
||||||
.then(sendWelcomeMessage(openId));
|
.then(sendWelcomeMessage(openId));
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
log.info("老用户关注服务号: memberId={}", existingMember.getId());
|
log.info("老用户关注服务号: memberId={}", existingMember.getId());
|
||||||
existingMember.setSubscribed(true);
|
existingMember.setSubscribed(true);
|
||||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(existingMember)
|
return memberRepository.save(existingMember)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
return clearMemberCache(saved.getId())
|
||||||
.then(sendWelcomeMessage(openId));
|
.then(sendWelcomeMessage(openId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
@@ -104,7 +117,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(existingMember)
|
return memberRepository.save(existingMember)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.then(sendWelcomeMessage(openId));
|
.then(sendWelcomeMessage(openId));
|
||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
@@ -122,7 +138,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||||
|
|
||||||
return memberRepository.save(existingMember)
|
return memberRepository.save(existingMember)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.doOnSuccess(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
clearMemberCache(saved.getId());
|
||||||
|
})
|
||||||
.then(sendWelcomeMessage(openId));
|
.then(sendWelcomeMessage(openId));
|
||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
@@ -148,8 +167,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
member.setSubscribed(false);
|
member.setSubscribed(false);
|
||||||
member.setLastLoginAt(LocalDateTime.now());
|
member.setLastLoginAt(LocalDateTime.now());
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(saved -> {
|
||||||
.then();
|
memberSyncer.sync(saved);
|
||||||
|
return clearMemberCache(saved.getId()).then();
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then()
|
.then()
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
@@ -213,7 +234,11 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
member.setOfficialOpenId(officialOpenId);
|
member.setOfficialOpenId(officialOpenId);
|
||||||
}
|
}
|
||||||
return memberRepository.save(member)
|
return memberRepository.save(member)
|
||||||
.doOnSuccess(memberSyncer::sync)
|
.flatMap(saved -> {
|
||||||
|
memberSyncer.sync(saved);
|
||||||
|
return clearMemberCache(saved.getId())
|
||||||
|
.then(Mono.just(saved));
|
||||||
|
})
|
||||||
.map(savedMember -> {
|
.map(savedMember -> {
|
||||||
log.info("关联成功, memberId: {}", savedMember.getId());
|
log.info("关联成功, memberId: {}", savedMember.getId());
|
||||||
return true;
|
return true;
|
||||||
@@ -268,10 +293,17 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取微信AccessToken
|
* 获取微信AccessToken
|
||||||
*
|
|
||||||
* TODO: 应该使用缓存,避免频繁请求
|
|
||||||
*/
|
*/
|
||||||
private Mono<String> getAccessToken() {
|
private Mono<String> getAccessToken() {
|
||||||
|
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + "mp";
|
||||||
|
|
||||||
|
return redisUtil.get(cacheKey, String.class)
|
||||||
|
.flatMap(cachedToken -> {
|
||||||
|
if (cachedToken != null) {
|
||||||
|
log.debug("从缓存获取服务号access_token");
|
||||||
|
return Mono.just(cachedToken);
|
||||||
|
}
|
||||||
|
|
||||||
String appId = wechatProperties.getMp().getAppId();
|
String appId = wechatProperties.getMp().getAppId();
|
||||||
String appSecret = wechatProperties.getMp().getAppSecret();
|
String appSecret = wechatProperties.getMp().getAppSecret();
|
||||||
|
|
||||||
@@ -285,11 +317,14 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
.accept(MediaType.APPLICATION_JSON)
|
.accept(MediaType.APPLICATION_JSON)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.map(response -> {
|
.flatMap(response -> {
|
||||||
if (response.containsKey("errcode")) {
|
if (response.containsKey("errcode")) {
|
||||||
throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg"));
|
throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg"));
|
||||||
}
|
}
|
||||||
return (String) response.get("access_token");
|
String accessToken = (String) response.get("access_token");
|
||||||
|
return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
.then(Mono.just(accessToken));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,4 +370,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
|||||||
return Mono.empty(); // 即使发送失败也不影响主流程
|
return Mono.empty(); // 即使发送失败也不影响主流程
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<Long> clearMemberCache(Long memberId) {
|
||||||
|
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||||
|
return redisUtil.delete(cacheKey)
|
||||||
|
.doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-10
@@ -1,12 +1,24 @@
|
|||||||
package cn.novalon.gym.manage.member.util;
|
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 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.Cipher;
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AES加密工具类
|
* AES加密工具类
|
||||||
@@ -16,24 +28,35 @@ import java.util.Base64;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@Component
|
||||||
public class AesUtil {
|
public class AesUtil {
|
||||||
|
|
||||||
private static final String ALGORITHM = "AES";
|
private static final String ALGORITHM = "AES";
|
||||||
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
|
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 解密
|
* AES 解密
|
||||||
*
|
*
|
||||||
* @param encryptedData 加密数据,Base64编码
|
* @param encryptedData 加密数据,Base64编码
|
||||||
* @param key AES密钥,Base64编码(32字节)
|
|
||||||
* @param iv 初始化向量IV,Base64编码(16字节)
|
|
||||||
* @return 解密后的字符串
|
* @return 解密后的字符串
|
||||||
*/
|
*/
|
||||||
public static String decrypt(String encryptedData, String key, String iv) {
|
public static String decrypt(String encryptedData) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] dataByte = Base64.getDecoder().decode(encryptedData);
|
byte[] dataByte = Base64.getDecoder().decode(encryptedData);
|
||||||
byte[] keyByte = Base64.getDecoder().decode(key);
|
byte[] keyByte = Base64.getDecoder().decode(KEY);
|
||||||
byte[] ivByte = Base64.getDecoder().decode(iv);
|
byte[] ivByte = Base64.getDecoder().decode(IV);
|
||||||
|
|
||||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
||||||
@@ -52,15 +75,13 @@ public class AesUtil {
|
|||||||
* AES 加密
|
* AES 加密
|
||||||
*
|
*
|
||||||
* @param data 原始数据
|
* @param data 原始数据
|
||||||
* @param key AES密钥,Base64编码(32字节)
|
|
||||||
* @param iv 初始化向量IV,Base64编码(16字节)
|
|
||||||
* @return Base64编码的加密数据
|
* @return Base64编码的加密数据
|
||||||
*/
|
*/
|
||||||
public static String encrypt(String data, String key, String iv) {
|
public static String encrypt(String data) {
|
||||||
try {
|
try {
|
||||||
byte[] dataByte = data.getBytes(StandardCharsets.UTF_8);
|
byte[] dataByte = data.getBytes(StandardCharsets.UTF_8);
|
||||||
byte[] keyByte = Base64.getDecoder().decode(key);
|
byte[] keyByte = Base64.getDecoder().decode(KEY);
|
||||||
byte[] ivByte = Base64.getDecoder().decode(iv);
|
byte[] ivByte = Base64.getDecoder().decode(IV);
|
||||||
|
|
||||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ public class WechatPhoneUtil {
|
|||||||
* @param phone 手机号
|
* @param phone 手机号
|
||||||
* @return 脱敏后的手机号,如:138****8000
|
* @return 脱敏后的手机号,如:138****8000
|
||||||
*/
|
*/
|
||||||
private String maskPhone(String phone) {
|
public static String maskPhone(String phone) {
|
||||||
if (phone == null || phone.length() < 7) {
|
if (phone == null || phone.length() < 7) {
|
||||||
return "***";
|
return "***";
|
||||||
}
|
}
|
||||||
|
|||||||
+92
@@ -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;
|
||||||
|
}
|
||||||
+96
@@ -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;
|
||||||
|
}
|
||||||
+7
-2
@@ -1,10 +1,12 @@
|
|||||||
package cn.novalon.gym.manage.member.vo;
|
package cn.novalon.gym.manage.member.vo;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.enums.GenderEnum;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,10 +32,13 @@ public class MemberInfoVO {
|
|||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
// 性别
|
// 性别
|
||||||
private Integer gender;
|
private GenderEnum gender;
|
||||||
|
|
||||||
|
// 性别描述
|
||||||
|
private String genderDesc;
|
||||||
|
|
||||||
// 生日
|
// 生日
|
||||||
private Date birthday;
|
private LocalDate birthday;
|
||||||
|
|
||||||
// 头像
|
// 头像
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS member_user (
|
|||||||
nickname VARCHAR(100), -- 昵称
|
nickname VARCHAR(100), -- 昵称
|
||||||
phone VARCHAR(255), -- 手机号(AES加密存储)
|
phone VARCHAR(255), -- 手机号(AES加密存储)
|
||||||
gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女
|
gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女
|
||||||
birthday TIMESTAMP, -- 生日
|
birthday DATE, -- 生日
|
||||||
address VARCHAR(500), -- 地址
|
address VARCHAR(500), -- 地址
|
||||||
avatar VARCHAR(500), -- 头像URL
|
avatar VARCHAR(500), -- 头像URL
|
||||||
subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
|
subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
|
||||||
|
|||||||
@@ -2,21 +2,23 @@
|
|||||||
wechat:
|
wechat:
|
||||||
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
|
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
|
||||||
mock-enabled: false
|
mock-enabled: false
|
||||||
|
|
||||||
miniapp:
|
miniapp:
|
||||||
app-id: wx4d480112b426100b
|
app-id: ${WECHAT_MINIAPP_APP_ID}
|
||||||
app-secret: 78548f0c0ff66c73d3e8b071897eb1e5
|
app-secret: ${WECHAT_MINIAPP_SECRET}
|
||||||
|
|
||||||
mp:
|
mp:
|
||||||
app-id: wx6f138c9aacc8a0e8
|
app-id: ${WECHAT_MP_APP_ID}
|
||||||
app-secret: 5df2e315e9268e96a43bb2cce1d2270b
|
app-secret: ${WECHAT_MP_SECRET}
|
||||||
token: test_token
|
token: ${WECHAT_MP_TOKEN}
|
||||||
aes-key: ${WECHAT_MP_AESKEY:test_aes_key}
|
aes-key: ${WECHAT_MP_AESKEY}
|
||||||
# 服务器回调地址(微信服务器推送事件的URL)
|
callback-url: ${WECHAT_MP_CALLBACK_URL}
|
||||||
callback-url: https://1me240209tk74.vicp.fun/api/member/auth/mp/callback
|
|
||||||
# 手机号加密配置
|
# 手机号加密配置
|
||||||
phone-encryption:
|
phone-encryption:
|
||||||
secret-key: nVnA99iBfyK0IE6SkcUYdVAaVrezyn2sLRdLfkIyWnY=
|
secret-key: ${PHONE_ENCRYPTION_SECRET_KEY}
|
||||||
iv: LMpG6Ih9mmfEAALOCeIJBw==
|
iv: ${PHONE_ENCRYPTION_IV}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
uris: http://localhost:9200 # ES 服务器地址(支持多个,逗号分隔)
|
uris: http://localhost:9200
|
||||||
@@ -139,6 +139,16 @@
|
|||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>gym-groupCourse</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
+9
-2
@@ -10,6 +10,8 @@ import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDeta
|
|||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
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.data.r2dbc.repository.config.EnableR2dbcRepositories;
|
||||||
import org.springframework.web.server.WebFilter;
|
import org.springframework.web.server.WebFilter;
|
||||||
|
|
||||||
@@ -17,9 +19,14 @@ import java.util.List;
|
|||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
|
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
|
||||||
ReactiveUserDetailsServiceAutoConfiguration.class })
|
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.sys.audit.repository" ,
|
||||||
"cn.novalon.gym.manage.gymmembercard.dao"})
|
"cn.novalon.gym.manage.gymmembercard.dao",
|
||||||
|
"cn.novalon.gym.manage.member.repository",
|
||||||
|
"cn.novalon.gym.manage.groupcourse.dao"
|
||||||
|
})
|
||||||
|
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
|
||||||
public class ManageApplication {
|
public class ManageApplication {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ManageApplication.class);
|
private static final Logger logger = LoggerFactory.getLogger(ManageApplication.class);
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package cn.novalon.gym.manage.app.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||||
|
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author:liwentao
|
||||||
|
* @date:2026/5/15-05-15-16:01
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(factory);
|
||||||
|
|
||||||
|
// 创建ObjectMapper并配置
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
|
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||||
|
om.activateDefaultTyping(om.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
|
||||||
|
|
||||||
|
// 使用GenericJackson2JsonRedisSerializer替代已弃用的方式
|
||||||
|
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(om);
|
||||||
|
|
||||||
|
// 使用StringRedisSerializer来序列化和反序列化redis的key值
|
||||||
|
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||||
|
template.setKeySerializer(stringSerializer);
|
||||||
|
template.setValueSerializer(genericJackson2JsonRedisSerializer);
|
||||||
|
template.setHashKeySerializer(stringSerializer);
|
||||||
|
template.setHashValueSerializer(genericJackson2JsonRedisSerializer);
|
||||||
|
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-1
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.app.config;
|
|||||||
|
|
||||||
|
|
||||||
import cn.novalon.gym.manage.file.handler.SysFileHandler;
|
import cn.novalon.gym.manage.file.handler.SysFileHandler;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
|
||||||
import cn.novalon.gym.manage.member.handler.MemberCardHandler;
|
import cn.novalon.gym.manage.member.handler.MemberCardHandler;
|
||||||
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
|
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
|
||||||
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
|
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
|
||||||
@@ -62,7 +64,9 @@ public class SystemRouter {
|
|||||||
PasswordDiagnosticHandler passwordDiagnosticHandler,
|
PasswordDiagnosticHandler passwordDiagnosticHandler,
|
||||||
MemberCardHandler memberCardHandler,
|
MemberCardHandler memberCardHandler,
|
||||||
MemberCardRecordHandler memberCardRecordHandler,
|
MemberCardRecordHandler memberCardRecordHandler,
|
||||||
MemberCardTransactionHandler memberCardTransactionHandler) {
|
MemberCardTransactionHandler memberCardTransactionHandler,
|
||||||
|
GroupCourseHandler groupCourseHandler,
|
||||||
|
GroupCourseBookingHandler groupCourseBookingHandler) {
|
||||||
|
|
||||||
return route()
|
return route()
|
||||||
// ========== 诊断路由 ==========
|
// ========== 诊断路由 ==========
|
||||||
@@ -250,6 +254,22 @@ public class SystemRouter {
|
|||||||
.GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange)
|
.GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange)
|
||||||
.GET("/api/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember)
|
.GET("/api/member-card-transactions/statistics/purchase/{memberId}", memberCardTransactionHandler::getPurchaseAmountByMember)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ========== 团课管理路由 =================
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ===== 团课课程管理 =====
|
||||||
|
.GET("/api/groupCourse/list", groupCourseHandler::getAllGroupCourse)
|
||||||
|
.POST("/api/groupCourse/page", groupCourseHandler::getGroupCoursesByPage)
|
||||||
|
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
|
||||||
|
|
||||||
|
// ===== 团课预约管理 =====
|
||||||
|
.POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse)
|
||||||
|
.POST("/api/groupCourse/booking/{bookingId}/cancel", groupCourseBookingHandler::cancelBooking)
|
||||||
|
.GET("/api/groupCourse/bookings/member/{memberId}", groupCourseBookingHandler::getBookingsByMemberId)
|
||||||
|
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
|
||||||
|
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
|
||||||
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ spring:
|
|||||||
url: jdbc:postgresql://localhost:55432/manage_system
|
url: jdbc:postgresql://localhost:55432/manage_system
|
||||||
user: novalon
|
user: novalon
|
||||||
password: novalon123
|
password: novalon123
|
||||||
enabled: false
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
validate-on-migrate: true
|
validate-on-migrate: true
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ spring:
|
|||||||
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
||||||
username: ${DB_USERNAME:postgres}
|
username: ${DB_USERNAME:novalon}
|
||||||
password: ${DB_PASSWORD:postgres}
|
password: ${DB_PASSWORD:novalon123}
|
||||||
pool:
|
pool:
|
||||||
initial-size: 10
|
initial-size: 10
|
||||||
max-size: 50
|
max-size: 50
|
||||||
@@ -25,11 +25,11 @@ spring:
|
|||||||
acquire-timeout: 5s
|
acquire-timeout: 5s
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
||||||
username: ${DB_USERNAME:postgres}
|
username: ${DB_USERNAME:novalon}
|
||||||
password: ${DB_PASSWORD:postgres}
|
password: ${DB_PASSWORD:novalon123}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
flyway:
|
flyway:
|
||||||
enabled: false
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
baseline-version: 0
|
baseline-version: 0
|
||||||
@@ -38,11 +38,24 @@ spring:
|
|||||||
user:
|
user:
|
||||||
name: disabled
|
name: disabled
|
||||||
password: disabled
|
password: disabled
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:novalon123}
|
||||||
|
timeout: 5000
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8 # 最大连接数
|
||||||
|
max-idle: 8 # 最大空闲连接
|
||||||
|
min-idle: 0 # 最小空闲连接
|
||||||
|
max-wait: -1ms # 连接等待时间
|
||||||
profiles:
|
profiles:
|
||||||
active: dev
|
active: dev
|
||||||
config:
|
config:
|
||||||
import: classpath:member-config.yml
|
import: classpath:member-config.yml
|
||||||
|
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package cn.novalon.gym.manage.common.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
|
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 配置类(响应式版本)
|
||||||
|
*
|
||||||
|
* @author 付嘉
|
||||||
|
* @date 2026-05-29
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置 ReactiveRedisTemplate
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
|
||||||
|
ReactiveRedisConnectionFactory connectionFactory) {
|
||||||
|
|
||||||
|
// 配置序列化上下文
|
||||||
|
RedisSerializationContext<String, Object> serializationContext =
|
||||||
|
RedisSerializationContext.<String, Object>newSerializationContext()
|
||||||
|
.key(StringRedisSerializer.UTF_8)
|
||||||
|
.value(new GenericJackson2JsonRedisSerializer())
|
||||||
|
.hashKey(StringRedisSerializer.UTF_8)
|
||||||
|
.hashValue(new GenericJackson2JsonRedisSerializer())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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('&', "&");
|
||||||
|
ESCAPE_MAP.put('<', "<");
|
||||||
|
ESCAPE_MAP.put('>', ">");
|
||||||
|
ESCAPE_MAP.put('"', """);
|
||||||
|
ESCAPE_MAP.put('\'', "'");
|
||||||
|
|
||||||
|
// 反向映射
|
||||||
|
UNESCAPE_MAP.put("&", '&');
|
||||||
|
UNESCAPE_MAP.put("<", '<');
|
||||||
|
UNESCAPE_MAP.put(">", '>');
|
||||||
|
UNESCAPE_MAP.put(""", '"');
|
||||||
|
UNESCAPE_MAP.put("'", '\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package cn.novalon.gym.manage.common.util;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 工具类(响应式版本)
|
||||||
|
*
|
||||||
|
* @author liwentao
|
||||||
|
* @date 2026/5/15
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RedisUtil {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReactiveRedisTemplate<String, Object> reactiveRedisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置值
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> set(String key, Object value) {
|
||||||
|
return reactiveRedisTemplate.opsForValue().set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置值并指定过期时间(秒)
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> setWithExpire(String key, Object value, long timeoutSeconds) {
|
||||||
|
return reactiveRedisTemplate.opsForValue().set(key, value, Duration.ofSeconds(timeoutSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取值
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> Mono<T> get(String key, Class<T> clazz) {
|
||||||
|
return reactiveRedisTemplate.opsForValue().get(key)
|
||||||
|
.map(obj -> clazz.isInstance(obj) ? (T) obj : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取值(返回 Object)
|
||||||
|
*/
|
||||||
|
public Mono<Object> get(String key) {
|
||||||
|
return reactiveRedisTemplate.opsForValue().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除key
|
||||||
|
*/
|
||||||
|
public Mono<Long> delete(String key) {
|
||||||
|
return reactiveRedisTemplate.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断key是否存在
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> hasKey(String key) {
|
||||||
|
return reactiveRedisTemplate.hasKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置过期时间(秒)
|
||||||
|
*/
|
||||||
|
public Mono<Boolean> expire(String key, long timeoutSeconds) {
|
||||||
|
return reactiveRedisTemplate.expire(key, Duration.ofSeconds(timeoutSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,8 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
+1
@@ -10,6 +10,7 @@ import cn.novalon.gym.manage.sys.core.query.SysUserQuery;
|
|||||||
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
|
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
|
||||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
|
import cn.novalon.gym.manage.sys.dto.response.UserResponse;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
import org.springframework.data.relational.core.query.Query;
|
import org.springframework.data.relational.core.query.Query;
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 团课相关表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 团课课程表
|
||||||
|
CREATE TABLE IF NOT EXISTS group_course (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
course_name VARCHAR(100) NOT NULL,
|
||||||
|
coach_id BIGINT,
|
||||||
|
course_type BIGINT,
|
||||||
|
start_time TIMESTAMP NOT NULL,
|
||||||
|
end_time TIMESTAMP NOT NULL,
|
||||||
|
max_members INTEGER DEFAULT 20,
|
||||||
|
current_members INTEGER DEFAULT 0,
|
||||||
|
status VARCHAR(1) DEFAULT '0',
|
||||||
|
location VARCHAR(255),
|
||||||
|
cover_image VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
create_by VARCHAR(50),
|
||||||
|
update_by VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 团课预约记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS group_course_booking (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
course_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
member_card_id BIGINT NOT NULL,
|
||||||
|
booking_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status VARCHAR(1) DEFAULT '0',
|
||||||
|
cancel_time TIMESTAMP,
|
||||||
|
create_by VARCHAR(50),
|
||||||
|
update_by VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE group_course IS '团课课程表';
|
||||||
|
COMMENT ON COLUMN group_course.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN group_course.course_name IS '课程名称';
|
||||||
|
COMMENT ON COLUMN group_course.coach_id IS '教练ID(关联sys_user)';
|
||||||
|
COMMENT ON COLUMN group_course.course_type IS '课程类型(如瑜伽/普拉提/动感单车)';
|
||||||
|
COMMENT ON COLUMN group_course.start_time IS '开始时间';
|
||||||
|
COMMENT ON COLUMN group_course.end_time IS '结束时间';
|
||||||
|
COMMENT ON COLUMN group_course.max_members IS '最大参与人数';
|
||||||
|
COMMENT ON COLUMN group_course.current_members IS '当前参与人数';
|
||||||
|
COMMENT ON COLUMN group_course.status IS '状态(0正常 1已取消 2已结束)';
|
||||||
|
COMMENT ON COLUMN group_course.location IS '上课地点';
|
||||||
|
COMMENT ON COLUMN group_course.cover_image IS '封面图URL';
|
||||||
|
COMMENT ON COLUMN group_course.description IS '课程描述';
|
||||||
|
COMMENT ON COLUMN group_course.create_by IS '创建人';
|
||||||
|
COMMENT ON COLUMN group_course.update_by IS '更新人';
|
||||||
|
COMMENT ON COLUMN group_course.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN group_course.updated_at IS '更新时间';
|
||||||
|
COMMENT ON COLUMN group_course.deleted_at IS '删除时间(软删除)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE group_course_booking IS '团课预约记录表';
|
||||||
|
COMMENT ON COLUMN group_course_booking.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN group_course_booking.course_id IS '团课ID';
|
||||||
|
COMMENT ON COLUMN group_course_booking.member_id IS '用户ID';
|
||||||
|
COMMENT ON COLUMN group_course_booking.member_card_id IS '会员卡ID';
|
||||||
|
COMMENT ON COLUMN group_course_booking.booking_time IS '预约时间';
|
||||||
|
COMMENT ON COLUMN group_course_booking.status IS '状态(0已预约 1已取消 2已出席 3缺席)';
|
||||||
|
COMMENT ON COLUMN group_course_booking.cancel_time IS '取消时间';
|
||||||
|
COMMENT ON COLUMN group_course_booking.create_by IS '创建人';
|
||||||
|
COMMENT ON COLUMN group_course_booking.update_by IS '更新人';
|
||||||
|
COMMENT ON COLUMN group_course_booking.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN group_course_booking.updated_at IS '更新时间';
|
||||||
|
COMMENT ON COLUMN group_course_booking.deleted_at IS '删除时间(软删除)';
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
-- 场景1: 0人预约,可预约(正常状态,6月15日,距开始还有14天)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('极速燃脂单车', 104, 2, '2026-06-15 19:30:00', '2026-06-15 20:20:00', 25, 0, 0, '单车房', '/images/spinning.jpg', '跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡。', 'admin', '2026-06-01 11:00:00', '2026-06-01 11:00:00');
|
||||||
|
|
||||||
|
-- 场景2: 已有人预约,可预约(正常状态,6月12日,5/15人)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('清晨流瑜伽', 101, 1, '2026-06-12 09:00:00', '2026-06-12 10:30:00', 15, 5, 0, 'A座3楼瑜伽教室', '/images/yoga_flow.jpg', '适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。', 'admin', '2026-06-01 10:00:00', '2026-06-01 10:00:00');
|
||||||
|
|
||||||
|
-- 场景3: 满员,不可预约(正常状态但已满员,6月10日,20/20人)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('燃脂搏击', 102, 2, '2026-06-10 18:30:00', '2026-06-10 19:30:00', 20, 20, 0, '综合训练区', '/images/kickboxing.jpg', '高强度间歇训练,配合音乐快速燃脂,释放压力。名额已满,无法预约。', 'coach_zhang', '2026-06-01 14:30:00', '2026-06-01 14:30:00');
|
||||||
|
|
||||||
|
-- 场景4: 超出可预约时间,不可预约(正常状态但距开始不足30分钟)
|
||||||
|
-- 当前时间: 2026-06-01 15:00,课程开始: 2026-06-01 15:20
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('哈他瑜伽', 101, 1, '2026-06-01 15:20:00', '2026-06-01 16:50:00', 12, 3, 0, '瑜伽教室B', '/images/hatha_yoga.jpg', '基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。', 'coach_li', '2026-06-01 08:00:00', '2026-06-01 08:00:00');
|
||||||
|
|
||||||
|
-- 场景5: 课程已取消,不可预约(status=1)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('周末冥想修复', 101, 1, '2026-06-20 15:00:00', '2026-06-20 16:00:00', 12, 3, 1, '冥想室', '/images/meditation.jpg', '通过呼吸和正念冥想,深度放松身心。该课程已被取消。', 'coach_wang', '2026-05-28 08:00:00', '2026-05-28 08:00:00');
|
||||||
|
|
||||||
|
-- 场景6: 课程已结束,不可预约(status=2)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('蜜桃臀塑造', 103, 3, '2026-05-30 19:00:00', '2026-05-30 20:00:00', 10, 8, 2, '私教专区', '/images/glute.jpg', '针对性训练臀部肌肉群,课程已于5月30日结束,无法预约。', 'coach_li', '2026-05-20 09:15:00', '2026-05-20 09:15:00');
|
||||||
|
|
||||||
|
-- 场景7(可选): 已结束但未满员的课程(status=2,即使有空位也不可预约)
|
||||||
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, create_by, created_at, updated_at) VALUES
|
||||||
|
('午间冥想放松', 101, 1, '2026-05-31 12:00:00', '2026-05-31 13:00:00', 15, 6, 2, '冥想室', '/images/meditation_noon.jpg', '午间冥想课程,已于5月31日结束。', 'admin', '2026-05-25 09:00:00', '2026-05-25 09:00:00');
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 1. member_user 表(会员表)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Step 1: 删除已存在的表(如果需要重建)
|
||||||
|
-- DROP TABLE IF EXISTS member_user CASCADE;
|
||||||
|
|
||||||
|
-- Step 2: 创建 member_user 表
|
||||||
|
CREATE TABLE IF NOT EXISTS member_user (
|
||||||
|
-- ========== 主键和基础字段(来自BaseEntity)==========
|
||||||
|
id BIGSERIAL PRIMARY KEY, -- 主键ID,自增
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间
|
||||||
|
|
||||||
|
-- ========== 会员核心字段 ==========
|
||||||
|
member_no VARCHAR(50) NOT NULL UNIQUE, -- 会员编号(唯一)
|
||||||
|
nickname VARCHAR(100), -- 昵称
|
||||||
|
phone VARCHAR(255), -- 手机号(AES加密存储)
|
||||||
|
gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女
|
||||||
|
birthday DATE, -- 生日
|
||||||
|
address VARCHAR(500), -- 地址
|
||||||
|
avatar VARCHAR(500), -- 头像URL
|
||||||
|
subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
|
||||||
|
last_login_at TIMESTAMP, -- 最后登录时间
|
||||||
|
|
||||||
|
-- ========== 微信相关字段 ==========
|
||||||
|
union_id VARCHAR(100), -- 微信UnionID(跨应用唯一标识)
|
||||||
|
miniapp_open_id VARCHAR(100), -- 小程序OpenID
|
||||||
|
official_open_id VARCHAR(100), -- 服务号OpenID
|
||||||
|
|
||||||
|
-- ========== 软删除字段 ==========
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE -- 是否删除(软删除标记)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: 创建索引
|
||||||
|
-- 会员编号索引(唯一索引,加速查询)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_member_user_member_no ON member_user(member_no);
|
||||||
|
|
||||||
|
-- UnionID索引(加速跨平台用户查找)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_user_union_id ON member_user(union_id);
|
||||||
|
|
||||||
|
-- 小程序OpenID索引(加速小程序登录查询)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_user_miniapp_openid ON member_user(miniapp_open_id);
|
||||||
|
|
||||||
|
-- 服务号OpenID索引(加速服务号事件处理)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_user_official_openid ON member_user(official_open_id);
|
||||||
|
|
||||||
|
-- 手机号索引(加速手机号查询和去重)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_user_phone ON member_user(phone);
|
||||||
|
|
||||||
|
-- 软删除索引(加速查询未删除的记录)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_user_is_deleted ON member_user(is_deleted);
|
||||||
|
|
||||||
|
-- Step 4: 添加注释
|
||||||
|
COMMENT ON TABLE member_user IS '会员表';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN member_user.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN member_user.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN member_user.updated_at IS '更新时间';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN member_user.member_no IS '会员编号(唯一,格式:MEM + 8位随机字符)';
|
||||||
|
COMMENT ON COLUMN member_user.nickname IS '昵称';
|
||||||
|
COMMENT ON COLUMN member_user.phone IS '手机号(AES-128-CBC加密存储)';
|
||||||
|
COMMENT ON COLUMN member_user.gender IS '性别:0-未知,1-男,2-女';
|
||||||
|
COMMENT ON COLUMN member_user.birthday IS '生日';
|
||||||
|
COMMENT ON COLUMN member_user.address IS '地址';
|
||||||
|
COMMENT ON COLUMN member_user.avatar IS '头像URL';
|
||||||
|
COMMENT ON COLUMN member_user.subscribed IS '是否关注服务号:true-已关注,false-未关注';
|
||||||
|
COMMENT ON COLUMN member_user.last_login_at IS '最后登录时间';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN member_user.union_id IS '微信UnionID(用户在开放平台的唯一标识,跨应用相同)';
|
||||||
|
COMMENT ON COLUMN member_user.miniapp_open_id IS '小程序OpenID(用户在当前小程序的唯一标识)';
|
||||||
|
COMMENT ON COLUMN member_user.official_open_id IS '服务号OpenID(用户在当前服务号的唯一标识)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN member_user.is_deleted IS '是否删除(软删除标记):false-正常,true-已删除';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. wechat_user 表(微信用户表)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Step 1: 删除已存在的表(如果需要重建)
|
||||||
|
-- DROP TABLE IF EXISTS wechat_user CASCADE;
|
||||||
|
|
||||||
|
-- Step 2: 创建 wechat_user 表
|
||||||
|
CREATE TABLE IF NOT EXISTS wechat_user (
|
||||||
|
-- ========== 主键和基础字段(来自BaseEntity)==========
|
||||||
|
id BIGSERIAL PRIMARY KEY, -- 主键ID,自增
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间
|
||||||
|
|
||||||
|
-- ========== 关联字段 ==========
|
||||||
|
member_id BIGINT NOT NULL, -- 会员ID(外键)
|
||||||
|
|
||||||
|
-- ========== 微信标识字段 ==========
|
||||||
|
union_id VARCHAR(100), -- 微信UnionID
|
||||||
|
miniapp_openid VARCHAR(100), -- 小程序OpenID
|
||||||
|
mp_openid VARCHAR(100), -- 服务号OpenID
|
||||||
|
|
||||||
|
-- ========== 关注状态字段 ==========
|
||||||
|
is_subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
|
||||||
|
subscribe_time TIMESTAMP, -- 首次关注时间
|
||||||
|
unsubscribe_time TIMESTAMP -- 最后一次取消关注时间
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: 创建外键约束
|
||||||
|
ALTER TABLE wechat_user
|
||||||
|
ADD CONSTRAINT fk_wechat_user_member
|
||||||
|
FOREIGN KEY (member_id) REFERENCES member_user(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Step 4: 创建索引
|
||||||
|
-- UnionID索引(加速跨平台用户查找)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wechat_user_union_id ON wechat_user(union_id);
|
||||||
|
|
||||||
|
-- 小程序OpenID索引(加速小程序登录查询)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wechat_user_miniapp_openid ON wechat_user(miniapp_openid);
|
||||||
|
|
||||||
|
-- 服务号OpenID索引(加速服务号事件处理)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wechat_user_mp_openid ON wechat_user(mp_openid);
|
||||||
|
|
||||||
|
-- 会员ID索引(加速关联查询)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wechat_user_member_id ON wechat_user(member_id);
|
||||||
|
|
||||||
|
-- Step 5: 添加注释
|
||||||
|
COMMENT ON TABLE wechat_user IS '微信用户表';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wechat_user.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN wechat_user.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN wechat_user.updated_at IS '更新时间';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wechat_user.member_id IS '会员ID(关联 member_user 表的 id 字段)';
|
||||||
|
COMMENT ON COLUMN wechat_user.union_id IS '微信UnionID(用户在开放平台的唯一标识)';
|
||||||
|
COMMENT ON COLUMN wechat_user.miniapp_openid IS '小程序OpenID(用户在当前小程序的唯一标识)';
|
||||||
|
COMMENT ON COLUMN wechat_user.mp_openid IS '服务号OpenID(用户在当前服务号的唯一标识)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wechat_user.is_subscribed IS '是否关注服务号:true-已关注,false-未关注';
|
||||||
|
COMMENT ON COLUMN wechat_user.subscribe_time IS '首次关注时间';
|
||||||
|
COMMENT ON COLUMN wechat_user.unsubscribe_time IS '最后一次取消关注时间';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. member_card 表(会员卡类型表)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS member_card (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
|
||||||
|
member_card_id BIGSERIAL,
|
||||||
|
member_card_name VARCHAR(100) NOT NULL,
|
||||||
|
member_card_type VARCHAR(20) NOT NULL,
|
||||||
|
member_card_price DECIMAL(10, 2) NOT NULL,
|
||||||
|
member_card_validity_days INTEGER,
|
||||||
|
member_card_total_times INTEGER,
|
||||||
|
member_card_amount DECIMAL(10, 2),
|
||||||
|
member_card_status INTEGER DEFAULT 1 NOT NULL,
|
||||||
|
extra_config TEXT DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE member_card IS '会员卡类型表';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_id IS '会员卡ID';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_name IS '会员卡名称';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_type IS '会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_price IS '会员卡价格';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_validity_days IS '有效天数(时长卡用)';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_total_times IS '总次数(次卡用)';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_amount IS '面额(储值卡用)';
|
||||||
|
COMMENT ON COLUMN member_card.member_card_status IS '状态:0-下架, 1-上架';
|
||||||
|
COMMENT ON COLUMN member_card.extra_config IS '扩展配置(JSON格式)';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. member_card_record 表(会员卡记录表)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS member_card_record (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
|
||||||
|
member_card_record_id BIGSERIAL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
member_card_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
remaining_times INTEGER DEFAULT 0,
|
||||||
|
remaining_amount DECIMAL(10, 2) DEFAULT 0.00,
|
||||||
|
expire_time TIMESTAMP,
|
||||||
|
source_order_id BIGINT,
|
||||||
|
purchase_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
card_composition TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 索引优化
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_record_member_id ON member_card_record(member_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_record_status ON member_card_record(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_record_expire_time ON member_card_record(expire_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_record_member_status ON member_card_record(member_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_record_status_expire ON member_card_record(status, expire_time)
|
||||||
|
WHERE status = 'ACTIVE';
|
||||||
|
|
||||||
|
COMMENT ON TABLE member_card_record IS '会员卡记录表(会员持有的卡)';
|
||||||
|
COMMENT ON COLUMN member_card_record.member_card_record_id IS '会员卡记录ID';
|
||||||
|
COMMENT ON COLUMN member_card_record.member_id IS '会员ID';
|
||||||
|
COMMENT ON COLUMN member_card_record.member_card_id IS '会员卡类型ID';
|
||||||
|
COMMENT ON COLUMN member_card_record.status IS '状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款';
|
||||||
|
COMMENT ON COLUMN member_card_record.remaining_times IS '剩余次数';
|
||||||
|
COMMENT ON COLUMN member_card_record.remaining_amount IS '剩余金额';
|
||||||
|
COMMENT ON COLUMN member_card_record.expire_time IS '到期时间';
|
||||||
|
COMMENT ON COLUMN member_card_record.source_order_id IS '来源订单ID';
|
||||||
|
COMMENT ON COLUMN member_card_record.purchase_time IS '购买时间';
|
||||||
|
COMMENT ON COLUMN member_card_record.version IS '乐观锁版本号';
|
||||||
|
COMMENT ON COLUMN member_card_record.card_composition IS '卡片组成(JSON格式,用于组合卡)';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. member_card_transactions 表(会员卡交易流水表)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS member_card_transactions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
member_card_record_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
member_card_id BIGINT NOT NULL,
|
||||||
|
operation_type VARCHAR(20) NOT NULL,
|
||||||
|
change_amount INTEGER DEFAULT 0,
|
||||||
|
change_balance DECIMAL(10, 2) DEFAULT 0.00,
|
||||||
|
after_remaining_count INTEGER DEFAULT 0,
|
||||||
|
after_remaining_balance DECIMAL(10, 2) DEFAULT 0.00,
|
||||||
|
related_biz_type VARCHAR(20),
|
||||||
|
source_order_id BIGINT,
|
||||||
|
remark VARCHAR(500),
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
archived_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 索引优化
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_transactions_member_id ON member_card_transactions(member_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_transactions_record_id ON member_card_transactions(member_card_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_transactions_created_at ON member_card_transactions(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_card_transactions_member_type_time
|
||||||
|
ON member_card_transactions(member_id, operation_type, created_at);
|
||||||
|
|
||||||
|
COMMENT ON TABLE member_card_transactions IS '会员卡交易流水表';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.operation_type IS '操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.change_amount IS '变动次数';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.change_balance IS '变动金额';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.after_remaining_count IS '变动后剩余次数';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.after_remaining_balance IS '变动后剩余金额';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.related_biz_type IS '关联业务类型:GROUP_CLASS-团课, PT_CLASS-私教, CHECK_IN-签到';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.is_archived IS '是否已归档';
|
||||||
|
COMMENT ON COLUMN member_card_transactions.archived_at IS '归档时间';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 6. refund_application 表(退款申请表)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS refund_application (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
|
||||||
|
record_id BIGINT NOT NULL,
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
reason VARCHAR(500),
|
||||||
|
apply_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
audit_time TIMESTAMP,
|
||||||
|
auditor_id BIGINT,
|
||||||
|
audit_remark VARCHAR(500),
|
||||||
|
refund_amount DECIMAL(10, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refund_application_record_id ON refund_application(record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refund_application_status ON refund_application(status);
|
||||||
|
|
||||||
|
COMMENT ON TABLE refund_application IS '退款申请表';
|
||||||
|
COMMENT ON COLUMN refund_application.status IS '状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败';
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 为团课预约记录表添加课程冗余字段
|
||||||
|
-- 用于保存预约时的课程快照信息
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
ALTER TABLE group_course_booking
|
||||||
|
ADD COLUMN IF NOT EXISTS course_name VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS course_start_time TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS course_end_time TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS location VARCHAR(255);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN group_course_booking.course_name IS '课程名称(冗余字段,保存预约时的课程快照)';
|
||||||
|
COMMENT ON COLUMN group_course_booking.course_start_time IS '课程开始时间(冗余字段,保存预约时的课程快照)';
|
||||||
|
COMMENT ON COLUMN group_course_booking.course_end_time IS '课程结束时间(冗余字段,保存预约时的课程快照)';
|
||||||
|
COMMENT ON COLUMN group_course_booking.location IS '上课地点(冗余字段,保存预约时的课程快照)';
|
||||||
+8
-1
@@ -50,7 +50,14 @@ public class SecurityConfig {
|
|||||||
spec.pathMatchers("/api/auth/**").permitAll()
|
spec.pathMatchers("/api/auth/**").permitAll()
|
||||||
.pathMatchers("/api/public/**").permitAll()
|
.pathMatchers("/api/public/**").permitAll()
|
||||||
.pathMatchers("/ws/**").permitAll()
|
.pathMatchers("/ws/**").permitAll()
|
||||||
.pathMatchers("/actuator/**").permitAll();
|
.pathMatchers("/actuator/**").permitAll()
|
||||||
|
.pathMatchers("/api/groupCourse/**").permitAll()
|
||||||
|
.pathMatchers("/api/member/**").permitAll()
|
||||||
|
.pathMatchers("/api/admin/member/**").permitAll()
|
||||||
|
.pathMatchers("/api/member-cards/**").permitAll()
|
||||||
|
.pathMatchers("/api/member-card-records/**").permitAll()
|
||||||
|
.pathMatchers("/api/member-card-transactions/**").permitAll();
|
||||||
|
|
||||||
|
|
||||||
if (isDevOrTest) {
|
if (isDevOrTest) {
|
||||||
spec.pathMatchers("/swagger-ui.html").permitAll()
|
spec.pathMatchers("/swagger-ui.html").permitAll()
|
||||||
|
|||||||
+1
@@ -15,6 +15,7 @@ import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
|
|||||||
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
|
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
|
||||||
import cn.novalon.gym.manage.sys.core.command.CreateUserCommand;
|
import cn.novalon.gym.manage.sys.core.command.CreateUserCommand;
|
||||||
import cn.novalon.gym.manage.sys.core.command.UpdateUserCommand;
|
import cn.novalon.gym.manage.sys.core.command.UpdateUserCommand;
|
||||||
|
import cn.novalon.gym.manage.sys.dto.response.UserResponse;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<module>manage-notify</module>
|
<module>manage-notify</module>
|
||||||
<module>manage-file</module>
|
<module>manage-file</module>
|
||||||
<module>gym-member</module>
|
<module>gym-member</module>
|
||||||
|
<module>gym-groupCourse</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -222,6 +223,18 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- HuTool工具箱-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>5.8.38</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
Reference in New Issue
Block a user