完成模块2-2.1团课预约

This commit is contained in:
2026-06-01 19:28:06 +08:00
parent a8c7a4061e
commit 03991319fd
37 changed files with 1911 additions and 270 deletions
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.groupcourse.converter;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; 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 cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -10,9 +12,19 @@ import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* 团课相关转换器
*
* @author 张翔
* @date 2026-06-01
*/
@Component @Component
@Slf4j @Slf4j
public class GroupCourseConverter { public class GroupCourseConverter {
/**
* 将团课实体转换为领域模型
*/
public GroupCourse toDomain(GroupCourseEntity entity){ public GroupCourse toDomain(GroupCourseEntity entity){
if(entity == null){ if(entity == null){
return null; return null;
@@ -23,6 +35,9 @@ public class GroupCourseConverter {
return groupCourse; return groupCourse;
} }
/**
* 将团课领域模型转换为实体
*/
public GroupCourseEntity toEntity(GroupCourse domain){ public GroupCourseEntity toEntity(GroupCourse domain){
if(domain == null){ if(domain == null){
return null; return null;
@@ -33,6 +48,9 @@ public class GroupCourseConverter {
return entity; return entity;
} }
/**
* 将团课实体列表转换为领域模型列表
*/
public List<GroupCourse> toDomainList(List<GroupCourseEntity> entities){ public List<GroupCourse> toDomainList(List<GroupCourseEntity> entities){
if (entities == null) { if (entities == null) {
return null; return null;
@@ -42,6 +60,9 @@ public class GroupCourseConverter {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 将团课领域模型列表转换为实体列表
*/
public List<GroupCourseEntity> toEntityList(List<GroupCourse> domains){ public List<GroupCourseEntity> toEntityList(List<GroupCourse> domains){
if (domains == null) { if (domains == null) {
return null; return null;
@@ -50,4 +71,57 @@ public class GroupCourseConverter {
.map(this::toEntity) .map(this::toEntity)
.collect(Collectors.toList()); .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());
}
} }
@@ -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);
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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() + "的预约");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -2,9 +2,9 @@
package cn.novalon.gym.manage.groupcourse.handler; package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest; 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.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.service.RedisService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,16 +22,16 @@ import java.util.Map;
public class GroupCourseHandler { public class GroupCourseHandler {
private final IGroupCourseService groupCourseService; private final IGroupCourseService groupCourseService;
private final Validator validator; private final Validator validator;
private final RedisService redisService; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public GroupCourseHandler(IGroupCourseService groupCourseService, public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator, Validator validator,
RedisService redisService, RedisUtil redisUtil,
ObjectMapper objectMapper){ ObjectMapper objectMapper){
this.groupCourseService = groupCourseService; this.groupCourseService = groupCourseService;
this.validator = validator; this.validator = validator;
this.redisService = redisService; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -88,8 +88,8 @@ public class GroupCourseHandler {
return ServerResponse.badRequest().bodyValue(error); return ServerResponse.badRequest().bodyValue(error);
} }
Object cachedValue = redisService.get(key); return redisUtil.get(key)
.map(cachedValue -> {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
if (cachedValue != null) { if (cachedValue != null) {
result.put("success", true); result.put("success", true);
@@ -116,13 +116,15 @@ public class GroupCourseHandler {
result.put("message", "缓存未命中"); result.put("message", "缓存未命中");
} }
return ServerResponse.ok().bodyValue(result); return result;
}) })
.flatMap(result -> ServerResponse.ok().bodyValue(result))
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> errorResponse = new HashMap<>(); Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false); errorResponse.put("success", false);
errorResponse.put("message", "请求处理失败: " + error.getMessage()); errorResponse.put("message", "请求处理失败: " + error.getMessage());
return ServerResponse.status(500).bodyValue(errorResponse); return ServerResponse.status(500).bodyValue(errorResponse);
}); });
});
} }
} }
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -1,76 +0,0 @@
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);
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -3,10 +3,10 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
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.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.service.RedisService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -15,15 +15,12 @@ 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.Duration;
import java.util.List;
@Service @Service
public class GroupCourseService implements IGroupCourseService { public class GroupCourseService implements IGroupCourseService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseService.class); private static final Logger logger = LoggerFactory.getLogger(GroupCourseService.class);
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final RedisService redisService; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
@@ -31,10 +28,10 @@ public class GroupCourseService implements IGroupCourseService {
private static final long CACHE_EXPIRE_SECONDS = 300; private static final long CACHE_EXPIRE_SECONDS = 300;
public GroupCourseService(IGroupCourseRepository groupCourseRepository, public GroupCourseService(IGroupCourseRepository groupCourseRepository,
RedisService redisService, RedisUtil redisUtil,
ObjectMapper objectMapper){ ObjectMapper objectMapper){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.redisService = redisService; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -42,38 +39,35 @@ public class GroupCourseService implements IGroupCourseService {
public Mono<GroupCourse> findById(Long id) { public Mono<GroupCourse> findById(Long id) {
String cacheKey = CACHE_KEY_ID_PREFIX + id; String cacheKey = CACHE_KEY_ID_PREFIX + id;
Object cachedData = redisService.get(cacheKey); return redisUtil.get(cacheKey, String.class)
if (cachedData != null) { .flatMap(cachedJson -> {
if (cachedJson != null) {
try { try {
String json; GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
if (cachedData instanceof String) {
json = (String) cachedData;
} else {
json = objectMapper.writeValueAsString(cachedData);
}
GroupCourse groupCourse = objectMapper.readValue(json, GroupCourse.class);
logger.info("缓存命中 - findById: id={}", id); logger.info("缓存命中 - findById: id={}", id);
return Mono.just(groupCourse); return Mono.just(groupCourse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
redisService.delete(cacheKey); return redisUtil.delete(cacheKey).then(Mono.empty());
} }
} }
return Mono.empty();
logger.debug("缓存未命中,查询数据库 - findById: id={}", id); })
return groupCourseRepository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(
.doOnSuccess(groupCourse -> { groupCourseRepository.findByIdAndDeletedAtIsNull(id)
if (groupCourse != null) { .flatMap(groupCourse -> {
try { try {
String jsonData = objectMapper.writeValueAsString(groupCourse); String jsonData = objectMapper.writeValueAsString(groupCourse);
redisService.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS); return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
logger.debug("缓存已设置 - findById: id={}", id); .thenReturn(groupCourse)
.doOnSuccess(gc -> logger.debug("缓存已设置 - findById: id={}", id));
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage()); logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
return Mono.just(groupCourse);
} }
} })
}); .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
);
} }
@Override @Override
@@ -94,29 +88,29 @@ public class GroupCourseService implements IGroupCourseService {
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) { public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
int size = pageRequest.getSize(); 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; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
Object cachedData = redisService.get(cacheKey); return redisUtil.get(cacheKey, String.class)
if (cachedData != null) { .flatMap(cachedJson -> {
if (cachedJson != null) {
try { try {
String json; PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
if (cachedData instanceof String) {
json = (String) cachedData;
} else {
json = objectMapper.writeValueAsString(cachedData);
}
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class)); objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
logger.info("缓存命中 - findByPage: key={}", cacheKey); logger.info("缓存命中 - findByPage: key={}", cacheKey);
return Mono.just(pageResponse); return Mono.just(pageResponse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
redisService.delete(cacheKey); return redisUtil.delete(cacheKey).then(Mono.empty());
} }
} }
return Mono.empty();
})
.switchIfEmpty(
Mono.defer(() -> {
logger.debug("缓存未命中,查询数据库 - findByPage: key={}", cacheKey); logger.debug("缓存未命中,查询数据库 - findByPage: key={}", cacheKey);
Mono<PageResponse<GroupCourse>> resultMono; Mono<PageResponse<GroupCourse>> resultMono;
if (includeDeleted) { if (includeDeleted) {
@@ -125,14 +119,18 @@ public class GroupCourseService implements IGroupCourseService {
resultMono = groupCourseRepository.findByPageAndNotDeleted(pageRequest); resultMono = groupCourseRepository.findByPageAndNotDeleted(pageRequest);
} }
return resultMono.doOnSuccess(pageResponse -> { return resultMono.flatMap(pageResponse -> {
try { try {
String jsonData = objectMapper.writeValueAsString(pageResponse); String jsonData = objectMapper.writeValueAsString(pageResponse);
redisService.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS); return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
logger.debug("缓存已设置 - findByPage: key={}", cacheKey); .thenReturn(pageResponse)
.doOnSuccess(pr -> logger.debug("缓存已设置 - findByPage: key={}", cacheKey));
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.error("缓存设置失败 - key: {}, error: {}", cacheKey, e.getMessage()); logger.error("缓存设置失败 - key: {}, error: {}", cacheKey, e.getMessage());
return Mono.just(pageResponse);
} }
}); });
})
);
} }
} }
@@ -3,7 +3,7 @@ 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.member.util.RedisUtil; 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;
@@ -15,7 +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.member.util.RedisUtil; 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;
@@ -17,30 +17,21 @@ 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.BeanConvertUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils; import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO; import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
import cn.novalon.gym.manage.member.vo.MemberDetailVO; 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 co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.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.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -5,7 +5,7 @@ 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.member.util.RedisUtil; 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;
@@ -4,7 +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.member.util.RedisUtil; 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;
@@ -4,8 +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.common.util.HtmlEscapeUtil;
import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.WechatLoginDto; import cn.novalon.gym.manage.member.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;
@@ -16,7 +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.member.util.RedisUtil; 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;
@@ -8,7 +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.member.util.RedisUtil; 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;
+6
View File
@@ -143,6 +143,12 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> </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>
@@ -23,7 +23,8 @@ import java.util.List;
"cn.novalon.gym.manage.db.dao", "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.member.repository",
"cn.novalon.gym.manage.groupcourse.dao"
}) })
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository") @EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
public class ManageApplication { public class ManageApplication {
@@ -1,43 +0,0 @@
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;
}
}
@@ -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
@@ -29,7 +29,7 @@ spring:
password: ${DB_PASSWORD:novalon123} 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
@@ -1,4 +1,4 @@
package cn.novalon.gym.manage.member.config; package cn.novalon.gym.manage.common.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -1,4 +1,4 @@
package cn.novalon.gym.manage.member.util; package cn.novalon.gym.manage.common.util;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveRedisTemplate;
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS group_course (
CREATE TABLE IF NOT EXISTS group_course_booking ( CREATE TABLE IF NOT EXISTS group_course_booking (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL, course_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, member_id BIGINT NOT NULL,
member_card_id BIGINT NOT NULL,
booking_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, booking_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(1) DEFAULT '0', status VARCHAR(1) DEFAULT '0',
cancel_time TIMESTAMP, cancel_time TIMESTAMP,
@@ -60,7 +61,8 @@ COMMENT ON COLUMN group_course.deleted_at IS '删除时间(软删除)';
COMMENT ON TABLE group_course_booking IS '团课预约记录表'; COMMENT ON TABLE group_course_booking IS '团课预约记录表';
COMMENT ON COLUMN group_course_booking.id IS '主键ID'; 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.course_id IS '团课ID';
COMMENT ON COLUMN group_course_booking.user_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.booking_time IS '预约时间';
COMMENT ON COLUMN group_course_booking.status IS '状态(0已预约 1已取消 2已出席 3缺席)'; 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.cancel_time IS '取消时间';
@@ -1,19 +1,28 @@
-- 测试数据1: 进行中的瑜伽课程 (已有部分学员) -- 场景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 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-10 09:00:00', '2026-05-10 10:30:00', 15, 8, '1', 'A座3楼瑜伽教室', '/images/yoga_flow.jpg', '适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量', 'admin', '2026-05-01 10:00:00', '2026-05-01 10:00:00'); ('极速燃脂单车', 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: 即将开始的搏击课 (几乎满员) -- 场景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 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-05-12 18:30:00', '2026-05-12 19:30:00', 20, 19, '1', '综合训练区', '/images/kickboxing.jpg', '高强度间歇训练,配合音乐快速燃脂,释放压力', 'coach_zhang', '2026-05-02 14:30:00', '2026-05-02 14:30:00'); ('清晨流瑜伽', 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: 已结束的私教小团课 (课程号已满) -- 场景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 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-04-25 19:00:00', '2026-04-25 20:00:00', 10, 10, '2', '私教专', '/images/glute.jpg', '针对性训练臀部肌肉群,打造完美臀线。小班教学,动作一对一纠正', 'coach_li', '2026-04-20 09:15:00', '2026-04-20 09:15:00'); ('燃脂搏击', 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: 即将开始的动感单车 (名额充足,尚未有人报名) -- 场景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 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-05-15 19:30:00', '2026-05-15 20:20:00', 25, 0, '0', '单车房', '/images/spinning.jpg', '跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡', 'admin', '2026-05-06 11:00:00', '2026-05-06 11:00:00'); ('哈他瑜伽', 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: 已删除/作废的课程 (deleted_at 不为空) -- 场景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, deleted_at) VALUES 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-03 15:00:00', '2026-05-03 16:00:00', 12, 3, '3', '冥想室', '/images/meditation.jpg', '通过呼吸和正念冥想,深度放松身心,缓解一周疲劳', 'coach_wang', '2026-04-28 08:00:00', '2026-04-28 08:00:00', '2026-04-29 16:20:00'); ('周末冥想修复', 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');
@@ -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-失败';
@@ -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 '上课地点(冗余字段,保存预约时的课程快照)';
@@ -51,7 +51,12 @@ public class SecurityConfig {
.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/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) {
+6
View File
@@ -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>
@@ -229,6 +230,11 @@
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>5.8.38</version> <version>5.8.38</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>