diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/converter/GroupCourseConverter.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/converter/GroupCourseConverter.java index 34e66c4..e8bcf07 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/converter/GroupCourseConverter.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/converter/GroupCourseConverter.java @@ -3,6 +3,8 @@ 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; @@ -10,9 +12,19 @@ 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; @@ -23,6 +35,9 @@ public class GroupCourseConverter { return groupCourse; } + /** + * 将团课领域模型转换为实体 + */ public GroupCourseEntity toEntity(GroupCourse domain){ if(domain == null){ return null; @@ -33,6 +48,9 @@ public class GroupCourseConverter { return entity; } + /** + * 将团课实体列表转换为领域模型列表 + */ public List toDomainList(List entities){ if (entities == null) { return null; @@ -42,6 +60,9 @@ public class GroupCourseConverter { .collect(Collectors.toList()); } + /** + * 将团课领域模型列表转换为实体列表 + */ public List toEntityList(List domains){ if (domains == null) { return null; @@ -50,4 +71,57 @@ public class GroupCourseConverter { .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 toBookingDomainList(List entities){ + if (entities == null) { + return null; + } + return entities.stream() + .map(this::toBookingDomain) + .collect(Collectors.toList()); + } + + /** + * 将团课预约领域模型列表转换为实体列表 + */ + public List toBookingEntityList(List domains){ + if (domains == null) { + return null; + } + return domains.stream() + .map(this::toBookingEntity) + .collect(Collectors.toList()); + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseBookingDao.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseBookingDao.java new file mode 100644 index 0000000..f61f0c9 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseBookingDao.java @@ -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 { + + /** + * 根据ID查询未删除的预约记录 + */ + Mono findByIdIsAndDeletedAtIsNull(Long id); + + /** + * 根据会员ID查询所有预约记录 + */ + Flux findByMemberIdAndDeletedAtIsNull(Long memberId); + + /** + * 根据会员ID查询所有预约记录(带排序) + */ + Flux findByMemberIdAndDeletedAtIsNull(Long memberId, Sort sort); + + /** + * 根据团课ID查询所有预约记录 + */ + Flux findByCourseIdAndDeletedAtIsNull(Long courseId); + + /** + * 根据团课ID和会员ID查询预约记录 + */ + Mono findByCourseIdAndMemberIdAndDeletedAtIsNull(Long courseId, Long memberId); + + /** + * 根据团课ID和状态查询预约记录 + */ + Flux findByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status); + + /** + * 查询会员在指定课程的有效预约(状态为已预约且未取消) + */ + Mono findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(Long courseId, Long memberId, String status); + + /** + * 根据会员卡ID查询预约记录 + */ + Flux findByMemberCardIdAndDeletedAtIsNull(Long memberCardId); + + /** + * 统计团课已预约人数 + */ + Mono countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status); +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseBooking.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseBooking.java new file mode 100644 index 0000000..e26423f --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseBooking.java @@ -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; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseBookingEntity.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseBookingEntity.java new file mode 100644 index 0000000..dbea5aa --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseBookingEntity.java @@ -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; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEvent.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEvent.java new file mode 100644 index 0000000..0933035 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEvent.java @@ -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; + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventListener.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventListener.java new file mode 100644 index 0000000..9ffa621 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventListener.java @@ -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() + "的预约"); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventPublisher.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventPublisher.java new file mode 100644 index 0000000..5c50bd6 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/event/BookingReminderEventPublisher.java @@ -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); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java new file mode 100644 index 0000000..3c83e9a --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java @@ -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 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 response = new HashMap<>(); + response.put("success", true); + response.put("message", "预约成功"); + response.put("data", booking); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + }); + } + + /** + * 取消预约 + */ + @Operation(summary = "取消预约", description = "会员取消已预约的团课") + public Mono 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 response = new HashMap<>(); + response.put("success", true); + response.put("message", "取消成功"); + response.put("data", booking); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + }); + } + + /** + * 查询会员预约记录 + */ + @Operation(summary = "查询会员预约记录", description = "根据会员ID查询所有预约记录") + public Mono 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 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 getBookingsByCourseId(ServerRequest request) { + Long courseId = Long.valueOf(request.pathVariable("courseId")); + + return ServerResponse.ok() + .body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java index db6c601..3886e98 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java @@ -2,9 +2,9 @@ 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 cn.novalon.gym.manage.groupcourse.service.RedisService; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,16 +22,16 @@ import java.util.Map; public class GroupCourseHandler { private final IGroupCourseService groupCourseService; private final Validator validator; - private final RedisService redisService; + private final RedisUtil redisUtil; private final ObjectMapper objectMapper; public GroupCourseHandler(IGroupCourseService groupCourseService, Validator validator, - RedisService redisService, + RedisUtil redisUtil, ObjectMapper objectMapper){ this.groupCourseService = groupCourseService; this.validator = validator; - this.redisService = redisService; + this.redisUtil = redisUtil; this.objectMapper = objectMapper; } @@ -88,41 +88,43 @@ public class GroupCourseHandler { return ServerResponse.badRequest().bodyValue(error); } - Object cachedValue = redisService.get(key); - - Map 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 ServerResponse.ok().bodyValue(result); - }) - .onErrorResume(error -> { - Map errorResponse = new HashMap<>(); - errorResponse.put("success", false); - errorResponse.put("message", "请求处理失败: " + error.getMessage()); - return ServerResponse.status(500).bodyValue(errorResponse); + return redisUtil.get(key) + .map(cachedValue -> { + Map 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 errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("message", "请求处理失败: " + error.getMessage()); + return ServerResponse.status(500).bodyValue(errorResponse); + }); }); } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseBookingRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseBookingRepository.java new file mode 100644 index 0000000..8ccc8ae --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseBookingRepository.java @@ -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 findById(Long id); + + /** + * 根据会员ID查询预约记录列表 + */ + Flux findByMemberId(Long memberId); + + /** + * 根据团课ID查询预约记录列表 + */ + Flux findByCourseId(Long courseId); + + /** + * 查询会员是否已预约某课程 + */ + Mono findByCourseIdAndMemberId(Long courseId, Long memberId); + + /** + * 查询会员在指定课程的有效预约 + */ + Mono findValidBooking(Long courseId, Long memberId); + + /** + * 统计课程有效预约人数 + */ + Mono countValidBookings(Long courseId); + + /** + * 保存预约记录 + */ + Mono save(GroupCourseBooking booking); + + /** + * 更新预约记录 + */ + Mono update(GroupCourseBooking booking); + + /** + * 根据会员卡ID查询预约记录 + */ + Flux findByMemberCardId(Long memberCardId); +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseBookingRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseBookingRepository.java new file mode 100644 index 0000000..8c4c347 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseBookingRepository.java @@ -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 findById(Long id) { + return groupCourseBookingDao.findByIdIsAndDeletedAtIsNull(id) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Flux findByMemberId(Long memberId) { + return groupCourseBookingDao.findByMemberIdAndDeletedAtIsNull(memberId, Sort.by(Sort.Direction.DESC, "bookingTime")) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Flux findByCourseId(Long courseId) { + return groupCourseBookingDao.findByCourseIdAndDeletedAtIsNull(courseId) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Mono findByCourseIdAndMemberId(Long courseId, Long memberId) { + return groupCourseBookingDao.findByCourseIdAndMemberIdAndDeletedAtIsNull(courseId, memberId) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Mono findValidBooking(Long courseId, Long memberId) { + return groupCourseBookingDao.findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(courseId, memberId, "0") + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Mono countValidBookings(Long courseId) { + return groupCourseBookingDao.countByCourseIdAndStatusAndDeletedAtIsNull(courseId, "0"); + } + + @Override + public Mono save(GroupCourseBooking booking) { + GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking); + return groupCourseBookingDao.save(entity) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Mono update(GroupCourseBooking booking) { + GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking); + return groupCourseBookingDao.save(entity) + .map(groupCourseConverter::toBookingDomain); + } + + @Override + public Flux findByMemberCardId(Long memberCardId) { + return groupCourseBookingDao.findByMemberCardIdAndDeletedAtIsNull(memberCardId) + .map(groupCourseConverter::toBookingDomain); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java new file mode 100644 index 0000000..915d660 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java @@ -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 bookCourse(Long courseId, Long memberId, Long memberCardId); + + /** + * 取消预约 + * + * @param bookingId 预约ID + * @param memberId 会员ID + * @return 取消后的预约记录 + */ + Mono cancelBooking(Long bookingId, Long memberId); + + /** + * 根据会员ID查询预约记录列表 + * + * @param memberId 会员ID + * @return 预约记录列表 + */ + Flux getBookingsByMemberId(Long memberId); + + /** + * 根据预约ID查询预约详情 + * + * @param bookingId 预约ID + * @return 预约记录 + */ + Mono getBookingById(Long bookingId); + + /** + * 根据团课ID查询预约记录列表 + * + * @param courseId 团课ID + * @return 预约记录列表 + */ + Flux getBookingsByCourseId(Long courseId); +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/RedisService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/RedisService.java deleted file mode 100644 index aa92e38..0000000 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/RedisService.java +++ /dev/null @@ -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 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 getSet(String key) { - return redisTemplate.opsForSet().members(key); - } -} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java new file mode 100644 index 0000000..2e664fd --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java @@ -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 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 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 validateAndIncrementBookingCount(Long courseId, Integer maxMembers) { + return redisService.incrementBookingCount(courseId) + .map(count -> { + // 同时查询数据库中的预约数进行双重验证 + // 这里简化处理,实际生产环境应该结合数据库查询 + return count.intValue(); + }); + } + + /** + * 释放锁并返回错误 + */ + private Mono releaseLockAndError(Long courseId, String requestId, String errorMessage) { + return redisService.releaseLock(courseId, requestId) + .then(Mono.error(new RuntimeException(errorMessage))); + } + + @Override + public Mono 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 getBookingsByMemberId(Long memberId) { + logger.debug("查询会员预约记录:memberId={}", memberId); + return bookingRepository.findByMemberId(memberId) + .doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId)); + } + + @Override + public Mono getBookingById(Long bookingId) { + logger.debug("查询预约详情:bookingId={}", bookingId); + return bookingRepository.findById(bookingId); + } + + @Override + public Flux getBookingsByCourseId(Long courseId) { + logger.debug("查询课程预约记录:courseId={}", courseId); + return bookingRepository.findByCourseId(courseId) + .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java new file mode 100644 index 0000000..59c3f25 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java @@ -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 reactiveRedisTemplate; + private final ObjectMapper objectMapper; + + public GroupCourseRedisService(ReactiveRedisTemplate 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 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 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 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 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 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 getBookingCount(Long courseId) { + String key = "booking_count:" + courseId; + return reactiveRedisTemplate.opsForValue() + .get(key) + .map(obj -> (Integer) obj) + .defaultIfEmpty(0); + } + + /** + * 增加课程预约人数 + */ + public Mono incrementBookingCount(Long courseId) { + String key = "booking_count:" + courseId; + return reactiveRedisTemplate.opsForValue() + .increment(key) + .doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count)); + } + + /** + * 减少课程预约人数 + */ + public Mono decrementBookingCount(Long courseId) { + String key = "booking_count:" + courseId; + return reactiveRedisTemplate.opsForValue() + .decrement(key) + .doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count)); + } +} \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java index 70a43cc..98b5ea9 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java @@ -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.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 cn.novalon.gym.manage.groupcourse.service.RedisService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; @@ -15,15 +15,12 @@ import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.List; - @Service public class GroupCourseService implements IGroupCourseService { private static final Logger logger = LoggerFactory.getLogger(GroupCourseService.class); private final IGroupCourseRepository groupCourseRepository; - private final RedisService redisService; + private final RedisUtil redisUtil; private final ObjectMapper objectMapper; 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; public GroupCourseService(IGroupCourseRepository groupCourseRepository, - RedisService redisService, + RedisUtil redisUtil, ObjectMapper objectMapper){ this.groupCourseRepository = groupCourseRepository; - this.redisService = redisService; + this.redisUtil = redisUtil; this.objectMapper = objectMapper; } @@ -42,38 +39,35 @@ public class GroupCourseService implements IGroupCourseService { public Mono findById(Long id) { String cacheKey = CACHE_KEY_ID_PREFIX + id; - Object cachedData = redisService.get(cacheKey); - if (cachedData != null) { - try { - String json; - if (cachedData instanceof String) { - json = (String) cachedData; - } else { - json = objectMapper.writeValueAsString(cachedData); - } - - GroupCourse groupCourse = objectMapper.readValue(json, GroupCourse.class); - logger.info("缓存命中 - findById: id={}", id); - return Mono.just(groupCourse); - } catch (JsonProcessingException e) { - logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); - redisService.delete(cacheKey); - } - } - - logger.debug("缓存未命中,查询数据库 - findById: id={}", id); - return groupCourseRepository.findByIdAndDeletedAtIsNull(id) - .doOnSuccess(groupCourse -> { - if (groupCourse != null) { + return redisUtil.get(cacheKey, String.class) + .flatMap(cachedJson -> { + if (cachedJson != null) { try { - String jsonData = objectMapper.writeValueAsString(groupCourse); - redisService.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS); - logger.debug("缓存已设置 - findById: id={}", id); + GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class); + logger.info("缓存命中 - findById: id={}", id); + return Mono.just(groupCourse); } catch (JsonProcessingException e) { - logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage()); + 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 @@ -94,45 +88,49 @@ public class GroupCourseService implements IGroupCourseService { public Mono> 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; + String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword; - Object cachedData = redisService.get(cacheKey); - if (cachedData != null) { - try { - String json; - if (cachedData instanceof String) { - json = (String) cachedData; - } else { - json = objectMapper.writeValueAsString(cachedData); - } - - PageResponse pageResponse = objectMapper.readValue(json, - 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()); - redisService.delete(cacheKey); - } - } - - logger.debug("缓存未命中,查询数据库 - findByPage: key={}", cacheKey); - Mono> resultMono; - if (includeDeleted) { - resultMono = groupCourseRepository.findByPage(pageRequest); - } else { - resultMono = groupCourseRepository.findByPageAndNotDeleted(pageRequest); - } - - return resultMono.doOnSuccess(pageResponse -> { - try { - String jsonData = objectMapper.writeValueAsString(pageResponse); - redisService.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS); - logger.debug("缓存已设置 - findByPage: key={}", cacheKey); - } catch (JsonProcessingException e) { - logger.error("缓存设置失败 - key: {}, error: {}", cacheKey, e.getMessage()); - } - }); + return redisUtil.get(cacheKey, String.class) + .flatMap(cachedJson -> { + if (cachedJson != null) { + try { + PageResponse 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> 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); + } + }); + }) + ); } } diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java index 0cbbd76..ae20bd4 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardRecordServiceImpl.java @@ -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.repository.MemberCardRecordRepository; 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 org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java index 87e345b..070a938 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberCardServiceImpl.java @@ -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.service.IMemberCardService; 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 org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java index cecdaed..e40ff5f 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java @@ -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.BeanConvertUtil; 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.MemberDetailVO; import cn.novalon.gym.manage.member.vo.MemberInfoVO; -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch.core.IndexResponse; -import co.elastic.clients.json.jackson.JacksonJsonpMapper; -import co.elastic.clients.transport.rest_client.RestClientTransport; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.http.HttpHost; -import org.elasticsearch.client.RestClient; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; /** diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java index 0e9da72..e505ef7 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/RefundApplicationServiceImpl.java @@ -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.repository.RefundApplicationRepository; 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 org.springframework.stereotype.Service; import reactor.core.publisher.Mono; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java index aac7753..99238ec 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatApiServiceImpl.java @@ -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.member.config.WechatProperties; 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.extern.slf4j.Slf4j; import org.springframework.http.MediaType; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java index a462a14..af116b4 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatAuthServiceImpl.java @@ -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.NotFoundException; import cn.novalon.gym.manage.common.exception.SystemException; -import cn.novalon.gym.manage.common.util.HtmlEscapeUtil; -import cn.novalon.gym.manage.member.config.WechatProperties; import cn.novalon.gym.manage.member.dto.WechatLoginDto; import cn.novalon.gym.manage.member.entity.Member; 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.EsSyncUtils; 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.vo.WechatLoginVO; import cn.novalon.gym.manage.sys.security.JwtTokenProvider; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java index 22a2d56..41aca11 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/WechatOfficialServiceImpl.java @@ -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.service.WechatOfficialService; 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 com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index 2644308..11b6479 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -143,6 +143,12 @@ org.springframework.boot spring-boot-starter-data-redis + + cn.novalon.gym.manage + gym-groupCourse + 1.0.0 + compile + diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java index a87aaef..beef525 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java @@ -23,7 +23,8 @@ import java.util.List; "cn.novalon.gym.manage.db.dao", "cn.novalon.gym.manage.sys.audit.repository" , "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") public class ManageApplication { diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/RedisConfig.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/RedisConfig.java deleted file mode 100644 index 12d804e..0000000 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/RedisConfig.java +++ /dev/null @@ -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 redisTemplate(RedisConnectionFactory factory) { - RedisTemplate 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; - } -} \ No newline at end of file diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index 996c1d1..52acb21 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -2,6 +2,8 @@ package cn.novalon.gym.manage.app.config; 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.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; @@ -62,7 +64,9 @@ public class SystemRouter { PasswordDiagnosticHandler passwordDiagnosticHandler, MemberCardHandler memberCardHandler, MemberCardRecordHandler memberCardRecordHandler, - MemberCardTransactionHandler memberCardTransactionHandler) { + MemberCardTransactionHandler memberCardTransactionHandler, + GroupCourseHandler groupCourseHandler, + GroupCourseBookingHandler groupCourseBookingHandler) { return route() // ========== 诊断路由 ========== @@ -249,6 +253,22 @@ public class SystemRouter { .GET("/api/member-card-transactions/statistics/deduct/{cardId}", memberCardTransactionHandler::getDeductCountByCardId) .GET("/api/member-card-transactions/statistics/renew", memberCardTransactionHandler::getRenewAmountByTimeRange) .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(); } diff --git a/gym-manage-api/manage-app/src/main/resources/application-dev.yml b/gym-manage-api/manage-app/src/main/resources/application-dev.yml index 443a17e..33e428e 100644 --- a/gym-manage-api/manage-app/src/main/resources/application-dev.yml +++ b/gym-manage-api/manage-app/src/main/resources/application-dev.yml @@ -15,7 +15,7 @@ spring: url: jdbc:postgresql://localhost:55432/manage_system user: novalon password: novalon123 - enabled: false + enabled: true locations: classpath:db/migration baseline-on-migrate: true validate-on-migrate: true diff --git a/gym-manage-api/manage-app/src/main/resources/application.yml b/gym-manage-api/manage-app/src/main/resources/application.yml index 39acf0e..294cdeb 100644 --- a/gym-manage-api/manage-app/src/main/resources/application.yml +++ b/gym-manage-api/manage-app/src/main/resources/application.yml @@ -29,7 +29,7 @@ spring: password: ${DB_PASSWORD:novalon123} driver-class-name: org.postgresql.Driver flyway: - enabled: false + enabled: true locations: classpath:db/migration baseline-on-migrate: true baseline-version: 0 diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java index 7321879..6b9f23c 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java @@ -1,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.Configuration; diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java index ea49f50..0482306 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java @@ -1,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.data.redis.core.ReactiveRedisTemplate; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_GroupCourse_table.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_GroupCourse_table.sql index 4b82d66..4aacedf 100644 --- a/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_GroupCourse_table.sql +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V6__Create_GroupCourse_table.sql @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS group_course ( CREATE TABLE IF NOT EXISTS group_course_booking ( id BIGSERIAL PRIMARY KEY, 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, status VARCHAR(1) DEFAULT '0', cancel_time TIMESTAMP, @@ -60,7 +61,8 @@ 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.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.status IS '状态(0已预约 1已取消 2已出席 3缺席)'; COMMENT ON COLUMN group_course_booking.cancel_time IS '取消时间'; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V7__Insert_GroupCourse_test_data.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V7__Insert_GroupCourse_test_data.sql index 30d85c7..5ff78b2 100644 --- a/gym-manage-api/manage-db/src/main/resources/db/migration/V7__Insert_GroupCourse_test_data.sql +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V7__Insert_GroupCourse_test_data.sql @@ -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 - ('清晨流瑜伽', 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 - ('燃脂搏击', 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 - ('蜜桃臀塑造', 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 - ('极速燃脂单车', 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 不为空) -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 - ('周末冥想修复', 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'); \ No newline at end of file +-- 场景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'); \ No newline at end of file diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V8__Create_Member_And_MemberCard.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V8__Create_Member_And_MemberCard.sql new file mode 100644 index 0000000..d9c3954 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V8__Create_Member_And_MemberCard.sql @@ -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-失败'; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V9__Add_GroupCourse_Booking_Snapshot_Fields.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V9__Add_GroupCourse_Booking_Snapshot_Fields.sql new file mode 100644 index 0000000..8a07dc7 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V9__Add_GroupCourse_Booking_Snapshot_Fields.sql @@ -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 '上课地点(冗余字段,保存预约时的课程快照)'; \ No newline at end of file diff --git a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java index 83ea7df..b83f541 100644 --- a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java +++ b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/config/SecurityConfig.java @@ -51,7 +51,12 @@ public class SecurityConfig { .pathMatchers("/api/public/**").permitAll() .pathMatchers("/ws/**").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) { diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index 4a8bb59..e84ef9d 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -43,6 +43,7 @@ manage-notify manage-file gym-member + gym-groupCourse @@ -229,7 +230,12 @@ hutool-all 5.8.38 - + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + +