Author SHA1 Message Date
liwentao 836f0e1cbf 修复协议错误 2026-06-29 20:34:25 +08:00
liwentao 44da3cab6e 修复构建失败文件 2026-06-29 12:45:12 +08:00
liwentao cf7e2560b5 更新多个功能 2026-06-29 07:35:07 +08:00
liwentao fa94f52b53 Merge remote-tracking branch 'origin/feature/manage-for-cuit' into feature/manage-for-cuit
# Conflicts:
#	gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java
2026-06-29 03:14:27 +08:00
liwentao 566e949588 变基至dev,并修复配置 2026-06-29 03:12:44 +08:00
liwentao e61fa6de00 新增答辩用后台管理系统 2026-06-29 03:08:42 +08:00
liwentao f1614c7d45 变基至dev,并修复配置 2026-06-26 14:13:57 +08:00
liwentao 886e2748d5 Merge remote-tracking branch 'origin/feature/manage-for-cuit' into feature/manage-for-cuit
# Conflicts:
#	gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java
2026-06-26 14:01:14 +08:00
liwentao 1a5aa9b3ef 新增答辩用后台管理系统 2026-06-26 13:57:13 +08:00
liwentao 0140bb0cc8 新增答辩用后台管理系统 2026-06-25 18:40:23 +08:00
376 changed files with 21642 additions and 27278 deletions
@@ -194,4 +194,47 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats))); .bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
} }
/**
* 管理员查询所有签到记录(支持排序)
*
* GET /api/checkIn/admin/records
*/
public Mono<ServerResponse> getAllSignInRecords(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
String sortBy = request.queryParam("sortBy").orElse("signInTime");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询所有签到记录, startDate: {}, endDate: {}, sortBy: {}, sortOrder: {}", startDate, endDate, sortBy, sortOrder);
return checkService.getAllSignInRecords(startDate, endDate, sortBy, sortOrder)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 管理员查询签到统计(不限会员)
*
* GET /api/checkIn/admin/statistics
*/
public Mono<ServerResponse> getAllSignInStatistics(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询签到统计, startDate: {}, endDate: {}", startDate, endDate);
return checkService.getAllSignInStats(startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
} }
@@ -57,6 +57,12 @@ public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Lo
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC") @Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime); Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录(支持动态排序)
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRangeSorted(LocalDateTime startTime, LocalDateTime endTime);
/** /**
* 统计会员在时间范围内的签到次数 * 统计会员在时间范围内的签到次数
*/ */
@@ -78,4 +78,20 @@ public interface ICheckInService {
* @return 签到统计VO * @return 签到统计VO
*/ */
Mono<SignInStatsVO> getDailySignInStats(LocalDate date); Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
/**
* 管理员查询所有签到记录(支持排序)
*
* @param startTime 开始时间
* @param endTime 结束时间
* @param sortBy 排序字段
* @param sortOrder 排序方向
* @return 签到记录列表(含会员姓名、卡类型)
*/
Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder);
/**
* 管理员查询签到统计(不限会员)
*/
Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime);
} }
@@ -16,11 +16,13 @@ import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants; import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -47,29 +49,38 @@ public class CheckServiceImpl implements ICheckInService {
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository; private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService; private final IGroupCourseBookingService groupCourseBookingService;
private final IMemberRepository memberRepository;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override @Override
public Mono<QRCodeVo> getQRCode(Long memberId) { public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始生成会员签到二维码, memberId: {}", memberId); log.info("开始查询会员信息, memberId: {}", memberId);
String qrContent = QRRedisKey.generateQrcodeContent(); return findValidMemberCard(memberId)
Map<String, Object> redisMap = new HashMap<>(); .flatMap(cardRecord -> {
redisMap.put("qrContent", qrContent); log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
redisMap.put("isUsed", false);
redisMap.put("memberId", memberId); log.info("开始生成二维码");
String qrContent = QRRedisKey.generateQrcodeContent();
Map<String, Object> redisMap = new HashMap<>();
redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false);
redisMap.put("memberId", memberId);
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
return redisUtil.setWithExpire( return redisUtil.setWithExpire(
RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(),
redisMap, redisMap,
getSecondsUntilEndOfDay() getSecondsUntilEndOfDay()
) )
.then(Mono.fromSupplier(() -> { .then(Mono.fromSupplier(() -> {
String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent, String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent,
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png"); BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now()); return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
})); }));
})
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
} }
@Override @Override
@@ -105,10 +116,9 @@ public class CheckServiceImpl implements ICheckInService {
} }
log.info("二维码匹配成功,memberId: {}", memberId); log.info("二维码匹配成功,memberId: {}", memberId);
Long memberCardRecordId = map.get("memberCardRecordId") != null Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
? ((Number) map.get("memberCardRecordId")).longValue() : null;
return processCheckIn(memberId, memberCardRecordId, map, qrContent); return processCheckIn(memberId, memberCardRecordId, map, qrContent);
} else { } else {
MyWebSocketHandler.sendFailure(qrContent, "二维码无效"); MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
return Mono.error(new RuntimeException("二维码无效")); return Mono.error(new RuntimeException("二维码无效"));
@@ -138,23 +148,54 @@ public class CheckServiceImpl implements ICheckInService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知 // 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "正在检查预约信息..."); MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡...");
// 检查是否有需要签到的团课预约 return memberCardRecordRepository.findById(memberCardRecordId)
return validateBooking(memberId, now) .switchIfEmpty(Mono.defer(() -> {
.then(Mono.defer(() -> { MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在");
redisMap.put("isUsed", true); return Mono.error(new RuntimeException("会员卡记录不存在"));
redisMap.put("checkInTime", now.format(DATE_FORMATTER)); }))
.flatMap(cardRecord -> {
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
return Mono.error(new RuntimeException("会员卡状态不正确"));
}
return saveSignInRecord(memberId, null, null) if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) {
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap)) MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期");
.then(Mono.defer(() -> { return Mono.error(new RuntimeException("会员卡已过期"));
String successMsg = buildSuccessResponse(now); }
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
log.info("签到成功, memberId: {}", memberId); // 发送实时进度通知
return Mono.just(successMsg); MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
}));
})); // 检查是否有需要签到的团课预约
return validateBooking(memberId, now)
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId())
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在");
return Mono.error(new RuntimeException("会员卡类型不存在"));
}))
.flatMap(card -> {
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
return deductCardUsage(cardRecord, card)
.flatMap(updatedRecord -> {
redisMap.put("isUsed", true);
redisMap.put("checkInTime", now.format(DATE_FORMATTER));
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId())
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
.then(Mono.defer(() -> {
String successMsg = buildSuccessResponse(now);
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId);
return Mono.just(successMsg);
}));
});
}));
});
} }
/** /**
@@ -392,6 +433,83 @@ public class CheckServiceImpl implements ICheckInService {
return vo; return vo;
} }
@Override
public Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
Flux<SignInRecord> recordFlux = signInRecordRepository.findByTimeRangeSorted(start, end);
return recordFlux
.flatMap(record -> {
// fetch member name
Mono<String> memberNameMono = memberRepository.findById(record.getMemberId())
.map(Member::getNickname)
.defaultIfEmpty("未知");
// fetch card type name
Mono<String> cardTypeMono = record.getMemberCardId() != null
? memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
.map(MemberCard::getMemberCardName)
.defaultIfEmpty("未知")
: Mono.just("-");
return Mono.zip(memberNameMono, cardTypeMono)
.map(tuple -> {
SignInRecordVO vo = convertToVO(record);
vo.setMemberName(tuple.getT1());
vo.setMemberCardType(tuple.getT2());
return vo;
});
})
.collectList()
.flatMapMany(list -> {
// in-memory sort
boolean asc = "asc".equalsIgnoreCase(sortOrder);
java.util.Comparator<SignInRecordVO> comparator;
switch (sortBy != null ? sortBy : "signInTime") {
case "id":
comparator = java.util.Comparator.comparing(SignInRecordVO::getId, java.util.Comparator.nullsLast(Long::compareTo));
break;
case "memberName":
comparator = java.util.Comparator.comparing(SignInRecordVO::getMemberName, java.util.Comparator.nullsLast(String::compareTo));
break;
case "signInTime":
default:
comparator = java.util.Comparator.comparing(SignInRecordVO::getSignInTime, java.util.Comparator.nullsLast(java.time.LocalDateTime::compareTo));
break;
}
if (!asc) {
comparator = comparator.reversed();
}
list.sort(comparator);
return Flux.fromIterable(list);
});
}
@Override
public Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
Long members = (Long) results[2];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(startTime);
stats.setEndDate(endTime);
stats.setUniqueMemberCount(members);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByTimeRange(start, end),
signInRecordRepository.countSuccessByTimeRange(start, end),
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
);
}
private long getSecondsUntilEndOfDay() { private long getSecondsUntilEndOfDay() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
@@ -59,6 +59,16 @@ public class SignInRecordVO {
*/ */
private String source; private String source;
/**
* 会员姓名(关联查询)
*/
private String memberName;
/**
* 会员卡类型名称(关联查询)
*/
private String memberCardType;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -14,6 +14,7 @@ import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -57,6 +58,9 @@ class CheckInModuleTest {
@Mock @Mock
private IGroupCourseBookingService groupCourseBookingService; private IGroupCourseBookingService groupCourseBookingService;
@Mock
private IMemberRepository memberRepository;
@Mock @Mock
private MemberCard mockMemberCard; private MemberCard mockMemberCard;
@@ -72,7 +76,7 @@ class CheckInModuleTest {
void setUp() { void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository, checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
memberCardRepository, signInRecordRepository, groupCourseBookingService); memberCardRepository, signInRecordRepository, groupCourseBookingService, memberRepository);
when(mockMemberCard.getId()).thenReturn(1L); when(mockMemberCard.getId()).thenReturn(1L);
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD"); when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-coach</artifactId>
<packaging>jar</packaging>
<name>Gym Coach</name>
<description>Coach Management Module - Course Start/End, Violation Tracking</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -1,15 +0,0 @@
package cn.novalon.gym.manage.coach.dao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
/**
* 教练违规记录 DAO
*
* @author 张翔
* @date 2026-07-20
*/
@Repository
public interface CoachViolationDao extends R2dbcRepository<CoachViolationEntity, Long> {
}
@@ -1,61 +0,0 @@
package cn.novalon.gym.manage.coach.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;
/**
* 教练违规记录实体类 - 对应 coach_violation 表
*
* @author 张翔
* @date 2026-07-20
*/
@Table("coach_violation")
public class CoachViolationEntity extends BaseEntity {
@Column("coach_id")
private Long coachId;
@Column("course_id")
private Long courseId;
@Column("violation_time")
private LocalDateTime violationTime;
@Column("violation_reason")
private String violationReason;
public Long getCoachId() {
return coachId;
}
public void setCoachId(Long coachId) {
this.coachId = coachId;
}
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public LocalDateTime getViolationTime() {
return violationTime;
}
public void setViolationTime(LocalDateTime violationTime) {
this.violationTime = violationTime;
}
public String getViolationReason() {
return violationReason;
}
public void setViolationReason(String violationReason) {
this.violationReason = violationReason;
}
}
@@ -1,42 +0,0 @@
package cn.novalon.gym.manage.coach.enums;
/**
* 团课预约状态枚举
*
* @author 张翔
* @date 2026-07-20
*/
public enum BookingStatus {
BOOKED("0", "已预约"),
CANCELLED("1", "已取消"),
ATTENDED("2", "已出席"),
ABSENT("3", "缺席"),
COACH_ABSENT("4", "教练缺席"),
LATE("5", "迟到");
private final String value;
private final String desc;
BookingStatus(String value, String desc) {
this.value = value;
this.desc = desc;
}
public String getValue() {
return value;
}
public String getDesc() {
return desc;
}
public static BookingStatus fromValue(String value) {
for (BookingStatus status : values()) {
if (status.value.equals(value)) {
return status;
}
}
return BOOKED;
}
}
@@ -1,30 +0,0 @@
package cn.novalon.gym.manage.coach.enums;
/**
* 教练违规原因枚举
*
* @author 张翔
* @date 2026-07-20
*/
public enum ViolationReason {
COACH_LATE("COACH_LATE", "教练迟到"),
COACH_ABSENT("COACH_ABSENT", "教练缺席"),
NOT_MANUAL_END("NOT_MANUAL_END", "教练未手动结课");
private final String value;
private final String desc;
ViolationReason(String value, String desc) {
this.value = value;
this.desc = desc;
}
public String getValue() {
return value;
}
public String getDesc() {
return desc;
}
}
@@ -1,68 +0,0 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.Map;
/**
* 教练开课/结课处理器
*
* @author 张翔
* @date 2026-07-20
*/
@Component
@Tag(name = "教练开课结课", description = "教练开课与结课操作")
public class CoachCourseHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseHandler.class);
private final CoachCourseService coachCourseService;
private final AuthUtil authUtil;
public CoachCourseHandler(CoachCourseService coachCourseService, AuthUtil authUtil) {
this.coachCourseService = coachCourseService;
this.authUtil = authUtil;
}
@Operation(summary = "教练开课", description = "教练手动开课,记录实际开课时间,若超时则判定迟到")
public Mono<ServerResponse> startCourse(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
Long coachId = authUtil.getMemberIdOrThrow(request);
return coachCourseService.startCourse(courseId, coachId)
.flatMap(result -> ServerResponse.ok().bodyValue(Map.of(
"message", "开课成功",
"status", result.getStatus(),
"actualStartTime", result.getActualStartTime() != null ? result.getActualStartTime().toString() : ""
)))
.onErrorResume(e -> {
logger.error("开课失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "教练结课", description = "教练手动结课,记录实际结课时间")
public Mono<ServerResponse> endCourse(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
Long coachId = authUtil.getMemberIdOrThrow(request);
return coachCourseService.endCourse(courseId, coachId)
.flatMap(result -> ServerResponse.ok().bodyValue(Map.of(
"message", "结课成功",
"status", result.getStatus(),
"actualEndTime", result.getActualEndTime() != null ? result.getActualEndTime().toString() : ""
)))
.onErrorResume(e -> {
logger.error("结课失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
}
@@ -1,118 +0,0 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.dto.request.CoachCreateRequest;
import cn.novalon.gym.manage.sys.dto.request.CoachUpdateRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
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.List;
import java.util.Map;
/**
* 教练管理处理器(从 manage-app 迁移至 gym-coach 模块)
*
* @author 张翔
* @date 2026-07-19
*/
@Component
@Tag(name = "教练管理", description = "教练相关操作")
public class CoachHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachHandler.class);
private final CoachCourseService coachCourseService;
private final Validator validator;
public CoachHandler(CoachCourseService coachCourseService, Validator validator) {
this.coachCourseService = coachCourseService;
this.validator = validator;
}
@Operation(summary = "获取所有教练", description = "获取系统中所有教练列表")
public Mono<ServerResponse> getAllCoaches(ServerRequest request) {
return ServerResponse.ok()
.body(coachCourseService.getAllCoaches(), SysUser.class);
}
@Operation(summary = "创建教练", description = "创建新教练(创建用户并分配教练角色)")
public Mono<ServerResponse> createCoach(ServerRequest request) {
return request.bodyToMono(CoachCreateRequest.class)
.flatMap(req -> {
var violations = validator.validate(req);
if (!violations.isEmpty()) {
Map<String, String> errors = new HashMap<>();
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
return ServerResponse.badRequest().bodyValue(errors);
}
return coachCourseService.createCoach(
req.getUsername(), req.getPassword(),
req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user))
.onErrorResume(e -> {
logger.error("创建教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
});
});
}
@Operation(summary = "更新教练信息", description = "更新教练基本信息(昵称、邮箱、手机号)")
public Mono<ServerResponse> updateCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CoachUpdateRequest.class)
.flatMap(req -> coachCourseService.updateCoach(id, req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.ok().bodyValue(user))
.switchIfEmpty(ServerResponse.notFound().build())
.onErrorResume(e -> {
logger.error("更新教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
}));
}
@Operation(summary = "禁用教练", description = "禁用教练账号,自动取消其所有非进行中的团课")
public Mono<ServerResponse> disableCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.disableCoach(id)
.then(ServerResponse.ok().bodyValue(Map.of("message", "教练已禁用")))
.onErrorResume(e -> {
logger.error("禁用教练失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "获取教练的团课", description = "获取指定教练教授的所有团课")
public Mono<ServerResponse> getCoachCourses(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.getCoachCourses(id)
.collectList()
.flatMap(courses -> ServerResponse.ok().bodyValue(courses))
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
}
@Operation(summary = "获取教练违规次数统计", description = "获取所有教练的违规次数")
public Mono<ServerResponse> getViolationCounts(ServerRequest request) {
return coachCourseService.getViolationCounts()
.collectList()
.flatMap(counts -> ServerResponse.ok().bodyValue(counts));
}
@Operation(summary = "获取教练违规记录", description = "获取指定教练的违规记录")
public Mono<ServerResponse> getCoachViolations(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.getCoachViolations(id)
.collectList()
.flatMap(violations -> ServerResponse.ok().bodyValue(violations))
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
}
}
@@ -1,178 +0,0 @@
package cn.novalon.gym.manage.coach.scheduler;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 教练课程定时调度器
*
* 功能:
* 1. 检查未手动开课的课程,超时标记为教练缺席(5)并记录违规
* 2. 检查未手动结课的课程,超时标记为自动结束(6)并记录违规
*
* @author 张翔
* @date 2026-07-20
*/
@Component
public class CoachCourseScheduler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
private static final long ONE_HOUR_MINUTES = 60;
private static final long END_GRACE_MINUTES = 10;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final DatabaseClient databaseClient;
private final RedisUtil redisUtil;
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
DatabaseClient databaseClient,
RedisUtil redisUtil) {
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.databaseClient = databaseClient;
this.redisUtil = redisUtil;
}
/**
* 每分钟检查一次:
* - status=0(NORMAL) 的课程是否已过开课缺席阈值
* - status=3(IN_PROGRESS) 或 status=7(COACH_LATE) 的课程是否已过结课宽限期
*/
@Scheduled(fixedRate = 60000)
public void checkAndProcessCourses() {
logger.debug("教练课程调度器开始检查");
LocalDateTime now = LocalDateTime.now();
// 1. 检查未开课的课程
processAbsentCourses(now)
.subscribe(
count -> {
if (count > 0) {
logger.info("教练课程调度器:处理了 {} 门教练缺席课程", count);
invalidateCache();
}
},
error -> logger.error("教练课程调度器(缺席检查)执行失败:{}", error.getMessage(), error)
);
// 2. 检查未结课的课程
processAutoEndCourses(now)
.subscribe(
count -> {
if (count > 0) {
logger.info("教练课程调度器:处理了 {} 门自动结束课程", count);
invalidateCache();
}
},
error -> logger.error("教练课程调度器(自动结课)执行失败:{}", error.getMessage(), error)
);
}
/**
* 处理缺席课程:status=0 且已过开课缺席阈值
*/
private Mono<Long> processAbsentCourses(LocalDateTime now) {
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
.filter(course -> isAbsentThresholdExceeded(course, now))
.flatMap(course -> markAsCoachAbsent(course, now))
.count();
}
/**
* 处理自动结课:status IN ('3','7') 且 end_time + 10分钟 已过
*/
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
LocalDateTime endThreshold = now.minusMinutes(END_GRACE_MINUTES);
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
String.valueOf(CourseStatus.COACH_LATE.getValue())},
endThreshold)
.flatMap(course -> markAsAutoEnded(course, now))
.count();
}
/**
* 判断课程是否已过缺席阈值
*/
private boolean isAbsentThresholdExceeded(GroupCourseEntity course, LocalDateTime now) {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return minutesSinceStart > 30;
} else {
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
return minutesSinceStart > thresholdB;
}
}
/**
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
*/
private Mono<GroupCourseEntity> markAsCoachAbsent(GroupCourseEntity course, LocalDateTime now) {
logger.info("课程 {} 教练缺席,标记为 COACH_ABSENT", course.getId());
return insertViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_ABSENT)
.then(groupCourseDao.updateToCoachAbsent(course.getId(), now, now))
.then(groupCourseBookingDao.updateStatusByCourseId(course.getId(), "0", "4"))
.thenReturn(course);
}
/**
* 标记课程为自动结束(6),记录违规
*/
private Mono<GroupCourseEntity> markAsAutoEnded(GroupCourseEntity course, LocalDateTime now) {
logger.info("课程 {} 未手动结课,标记为 AUTO_ENDED", course.getId());
return insertViolation(course.getCoachId(), course.getId(), now, ViolationReason.NOT_MANUAL_END)
.then(groupCourseDao.updateToAutoEnded(course.getId(), now, now))
.thenReturn(course);
}
/**
* 插入违规记录(使用 DatabaseClient 直连)
*/
private Mono<Void> insertViolation(Long coachId, Long courseId, LocalDateTime violationTime,
ViolationReason reason) {
return databaseClient.sql("""
INSERT INTO coach_violation (coach_id, course_id, violation_time, violation_reason, created_at, updated_at)
VALUES (:coachId, :courseId, :violationTime, :reason, :now, :now)
""")
.bind("coachId", coachId)
.bind("courseId", courseId)
.bind("violationTime", violationTime)
.bind("reason", reason.getValue())
.bind("now", LocalDateTime.now())
.then();
}
/**
* 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效
*/
private void invalidateCache() {
redisUtil.deleteByPattern("datacount:statistics:*").subscribe(
deleted -> logger.debug("调度器清除统计缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除统计缓存失败: {}", error.getMessage())
);
redisUtil.deleteByPattern("group_course:*").subscribe(
deleted -> logger.debug("调度器清除团课缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除团课缓存失败: {}", error.getMessage())
);
}
}
@@ -1,357 +0,0 @@
package cn.novalon.gym.manage.coach.service;
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.UserRole;
import cn.novalon.gym.manage.sys.core.repository.ISysRoleRepository;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 教练课程服务(含教练管理 + 开课/结课逻辑 + 违规记录)
*
* @author 张翔
* @date 2026-07-20
*/
@Service
public class CoachCourseService {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
private static final String COACH_ROLE_NAME = "教练";
private static final long ONE_HOUR_MINUTES = 60;
private final ISysUserRepository userRepository;
private final ISysRoleRepository roleRepository;
private final IUserRoleRepository userRoleRepository;
private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final CoachViolationDao violationDao;
private final DatabaseClient databaseClient;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
public CoachCourseService(ISysUserRepository userRepository,
ISysRoleRepository roleRepository,
IUserRoleRepository userRoleRepository,
IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository,
GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
CoachViolationDao violationDao,
DatabaseClient databaseClient,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository;
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.violationDao = violationDao;
this.databaseClient = databaseClient;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
}
// ==================== 教练管理(从原 CoachService 迁移) ====================
public Flux<SysUser> getAllCoaches() {
return getCoachRoleId()
.flatMapMany(roleId -> userRoleRepository.findByRoleId(roleId)
.map(UserRole::getUserId)
.collectList()
.flatMapMany(userIds -> {
if (userIds.isEmpty()) {
return Flux.empty();
}
return Flux.fromIterable(userIds)
.flatMap(userRepository::findById)
.filter(user -> user.getDeletedAt() == null);
}));
}
public Mono<SysUser> createCoach(String username, String password, String nickname, String email, String phone) {
return getCoachRoleId().flatMap(coachRoleId -> {
SysUser user = new SysUser();
user.generateId();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(nickname);
user.setEmail(email);
user.setPhone(phone);
user.setStatus(StatusConstants.ENABLED);
return userRepository.save(user)
.flatMap(saved -> {
UserRole userRole = new UserRole();
userRole.setUserId(saved.getId());
userRole.setRoleId(coachRoleId);
return userRoleRepository.save(userRole).thenReturn(saved);
});
});
}
public Mono<SysUser> updateCoach(Long id, String nickname, String email, String phone) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user -> {
if (nickname != null) user.setNickname(nickname);
if (email != null) user.setEmail(email);
if (phone != null) user.setPhone(phone);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user);
});
}
@Transactional(transactionManager = "connectionFactoryTransactionManager")
public Mono<Void> disableCoach(Long id) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user ->
groupCourseRepository.countByCoachIdAndStatus(id, 3L)
.flatMap(inProgressCount -> {
if (inProgressCount > 0) {
return Mono.error(new RuntimeException(
"该教练有 " + inProgressCount + " 门正在进行中的团课,无法禁用"));
}
return groupCourseRepository.cancelCoursesByCoachIdExceptStatus(id, 3L)
.then();
})
.then(Mono.defer(() -> {
user.setStatus(StatusConstants.DISABLED);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user).then();
}))
)
.then(invalidateStatisticsCache());
}
public Flux<GroupCourse> getCoachCourses(Long coachId) {
return groupCourseRepository.findByCoachId(coachId, Sort.by(Sort.Direction.DESC, "startTime"))
.flatMap(course ->
bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
);
}
public Mono<Long> getCoachRoleId() {
return roleRepository.findByRoleName(COACH_ROLE_NAME)
.map(SysRole::getId)
.switchIfEmpty(Mono.error(new RuntimeException("教练角色未找到,请先执行数据库迁移")));
}
// ==================== 开课逻辑 ====================
/**
* 教练手动开课
* 判定逻辑:
* - 长课时(>=1h): 10分钟内正常,10~30分钟迟到,>30分钟拒绝
* - 短课时(<1h): 10%时长内正常,10%~25%迟到,>25%拒绝
*/
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
// 验证教练身份
if (!course.getCoachId().equals(coachId)) {
return Mono.error(new RuntimeException("您不是该课程的教练,无权开课"));
}
// 验证课程状态:只有 NORMAL(0) 可以开课
if (!CourseStatus.NORMAL.getValue().equals(course.getStatus())) {
return Mono.error(new RuntimeException("当前课程状态不允许开课,当前状态: " + course.getStatus()));
}
LocalDateTime now = LocalDateTime.now();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return handleLongCourseStart(course, now);
} else {
return handleShortCourseStart(course, now, courseDurationMinutes);
}
});
}
private Mono<GroupCourseEntity> handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= 10) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= 30) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >30分钟,拒绝(调度器应已标记为缺席)
return Mono.error(new RuntimeException("已超过开课时间30分钟,无法开课"));
}
private Mono<GroupCourseEntity> handleShortCourseStart(GroupCourseEntity course, LocalDateTime now,
long courseDurationMinutes) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
long thresholdA = Math.max(1, (long) (courseDurationMinutes * 0.10));
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= thresholdA) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= thresholdB) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >thresholdB,拒绝
return Mono.error(new RuntimeException("已超过开课时间,无法开课"));
}
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
CourseStatus newStatus, ViolationReason violationReason) {
course.setStatus(newStatus.getValue());
course.setActualStartTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateStartInfo(course.getId(), String.valueOf(newStatus.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
}
// ==================== 结课逻辑 ====================
/**
* 教练手动结课
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
* 必须在标注结课时间 + 10分钟内
*/
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
// 验证教练身份
if (!course.getCoachId().equals(coachId)) {
return Mono.error(new RuntimeException("您不是该课程的教练,无权结课"));
}
// 验证状态:IN_PROGRESS(3) 或 COACH_LATE(7)
Long status = course.getStatus();
if (!CourseStatus.IN_PROGRESS.getValue().equals(status)
&& !CourseStatus.COACH_LATE.getValue().equals(status)) {
return Mono.error(new RuntimeException("当前课程状态不允许结课,当前状态: " + status));
}
LocalDateTime now = LocalDateTime.now();
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
if (minutesAfterEnd > 10) {
return Mono.error(new RuntimeException("已超过结课时间10分钟,请等待系统自动结课"));
}
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(), String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
});
}
// ==================== 违规记录 ====================
/**
* 记录教练违规行为(使用 DatabaseClient 直连,避免 R2DBC Entity 映射问题)
*/
public Mono<Void> recordViolation(Long coachId, Long courseId, LocalDateTime violationTime,
ViolationReason reason) {
LocalDateTime now = LocalDateTime.now();
return databaseClient.sql("""
INSERT INTO coach_violation (coach_id, course_id, violation_time, violation_reason, created_at, updated_at)
VALUES (:coachId, :courseId, :violationTime, :reason, :now, :now)
""")
.bind("coachId", coachId)
.bind("courseId", courseId)
.bind("violationTime", violationTime)
.bind("reason", reason.getValue())
.bind("now", now)
.then();
}
// ==================== 违规查询 ====================
/**
* 获取所有教练的违规次数统计
*/
public Flux<Map<String, Object>> getViolationCounts() {
return databaseClient.sql("""
SELECT coach_id, COUNT(*) AS count
FROM coach_violation
WHERE deleted_at IS NULL
GROUP BY coach_id
""")
.fetch()
.all();
}
/**
* 获取指定教练的违规记录
*/
public Flux<Map<String, Object>> getCoachViolations(Long coachId) {
return databaseClient.sql("""
SELECT v.*, gc.course_name
FROM coach_violation v
LEFT JOIN group_course gc ON v.course_id = gc.id AND gc.deleted_at IS NULL
WHERE v.coach_id = :coachId AND v.deleted_at IS NULL
ORDER BY v.violation_time DESC
""")
.bind("coachId", coachId)
.fetch()
.all();
}
/**
* 清除统计缓存和团课缓存 —— 课程状态变更后调用,返回 Mono 确保链式执行
*/
private Mono<Void> invalidateStatisticsCache() {
return redisUtil.deleteByPattern("datacount:statistics:*")
.then(redisUtil.deleteByPattern("group_course:*"))
.doOnNext(count -> {})
.then();
}
}
+1 -6
View File
@@ -67,12 +67,7 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
</dependency> <version>5.2.5</version>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency> </dependency>
<!-- Gym Modules --> <!-- Gym Modules -->
@@ -6,7 +6,6 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Map;
/** /**
* 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询 * 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询
@@ -27,7 +26,7 @@ public class DataStatisticsDao {
* 统计指定时间范围内新增会员数 * 统计指定时间范围内新增会员数
*/ */
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) { public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND deleted_at IS NULL")
.bind("startTime", startTime) .bind("startTime", startTime)
.bind("endTime", endTime) .bind("endTime", endTime)
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
@@ -38,7 +37,7 @@ public class DataStatisticsDao {
* 统计总会员数 * 统计总会员数
*/ */
public Mono<Long> countTotalMembers() { public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE deleted_at IS NULL")
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
.one(); .one();
} }
@@ -181,68 +180,4 @@ public class DataStatisticsDao {
this.count = count; this.count = count;
} }
} }
// ========== 教练相关统计 ==========
/** 统计教练总数(角色为"教练"的用户) */
public Mono<Long> countTotalCoaches() {
return databaseClient.sql("""
SELECT COUNT(*) FROM sys_user u
INNER JOIN user_role ur ON u.id = ur.user_id
INNER JOIN sys_role sr ON ur.role_id = sr.id
WHERE sr.role_name = '教练' AND u.deleted_at IS NULL
""")
.map(row -> row.get(0, Long.class))
.one();
}
/** 统计违规总数(时间范围) */
public Mono<Long> countTotalViolations(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(*) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/** 按违规类型统计次数 */
public Flux<Map<String, Object>> countViolationsByReason(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT violation_reason, COUNT(*) AS count
FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
GROUP BY violation_reason
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.fetch()
.all();
}
/** 统计有违规记录的教练数 */
public Mono<Long> countViolatedCoaches(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(DISTINCT coach_id) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/** 统计区间内开课的团课数 */
public Mono<Long> countCourses(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(*) FROM group_course
WHERE start_time >= :startTime AND start_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
} }
@@ -1,40 +0,0 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 教练统计数据
*
* @author 张翔
* @date 2026-07-20
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CoachStatistics {
/** 教练总数 */
private Long totalCoaches;
/** 违规总数 */
private Long totalViolations;
/** 迟到次数 */
private Long lateCount;
/** 缺席次数 */
private Long absentCount;
/** 未手动结课次数 */
private Long notManualEndCount;
/** 有违规记录的教练数 */
private Long violatedCoaches;
/** 统计区间内开课的团课数 */
private Long totalCourses;
}
@@ -59,12 +59,6 @@ public class DataStatistics {
public static final String WEEK = "WEEK"; public static final String WEEK = "WEEK";
/** 月统计 */ /** 月统计 */
public static final String MONTH = "MONTH"; public static final String MONTH = "MONTH";
/** 近30天 */
public static final String LAST_30_DAYS = "LAST_30_DAYS";
/** 近90天 */
public static final String LAST_90_DAYS = "LAST_90_DAYS";
/** 今年 */
public static final String YEAR = "YEAR";
private PeriodType() {} private PeriodType() {}
} }
@@ -37,11 +37,6 @@ public class StatisticsSummary {
*/ */
private SignInStatistics signInStatistics; private SignInStatistics signInStatistics;
/**
* 教练统计数据
*/
private CoachStatistics coachStatistics;
/** /**
* 统计数据生成时间 * 统计数据生成时间
*/ */
@@ -4,6 +4,8 @@ import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService; import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -25,6 +27,8 @@ import java.time.format.DateTimeFormatter;
@Tag(name = "数据统计", description = "数据统计相关操作") @Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler { public class DataStatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsHandler.class);
@Autowired @Autowired
private IDataStatisticsService dataStatisticsService; private IDataStatisticsService dataStatisticsService;
@@ -35,6 +39,7 @@ public class DataStatisticsHandler {
return dataStatisticsService.getStatisticsSummaryWithCache(query) return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary)) .flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> { .onErrorResume(e -> {
log.error("获取综合统计数据失败", e);
StatisticsSummary errorSummary = StatisticsSummary.builder() StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
@@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -22,7 +23,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -171,67 +174,108 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
return count != null ? count : 0L; return count != null ? count : 0L;
} }
private Mono<CoachStatistics> getCoachStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
Mono<Long> totalCoachesMono = dataStatisticsDao.countTotalCoaches();
Mono<Long> totalViolationsMono = dataStatisticsDao.countTotalViolations(startTime, endTime);
Mono<Long> violatedCoachesMono = dataStatisticsDao.countViolatedCoaches(startTime, endTime);
Mono<Long> totalCoursesMono = dataStatisticsDao.countCourses(startTime, endTime);
Mono<Map<String, Long>> violationByReasonMono = dataStatisticsDao.countViolationsByReason(startTime, endTime)
.collectMap(row -> (String) row.get("violation_reason"),
row -> ((Number) row.get("count")).longValue());
return Mono.zip(totalCoachesMono, totalViolationsMono, violatedCoachesMono, totalCoursesMono, violationByReasonMono)
.map(tuple -> {
Map<String, Long> reasonMap = tuple.getT5();
return CoachStatistics.builder()
.totalCoaches(tuple.getT1())
.totalViolations(tuple.getT2())
.lateCount(reasonMap.getOrDefault("COACH_LATE", 0L))
.absentCount(reasonMap.getOrDefault("COACH_ABSENT", 0L))
.notManualEndCount(reasonMap.getOrDefault("NOT_MANUAL_END", 0L))
.violatedCoaches(tuple.getT3())
.totalCourses(tuple.getT4())
.build();
});
}
@Override @Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) { public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query); String statDate = query.getStartTime() != null
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query); ? query.getStartTime().toLocalDate().toString()
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query); : LocalDateTime.now().toLocalDate().toString();
Mono<CoachStatistics> coachStatsMono = getCoachStatistics(query);
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono, coachStatsMono) Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query)
.onErrorResume(e -> {
log.error("获取会员统计数据失败", e);
return Mono.just(MemberStatistics.builder().statDate(statDate).build());
});
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query)
.onErrorResume(e -> {
log.error("获取预约统计数据失败", e);
return Mono.just(BookingStatistics.builder().statDate(statDate).build());
});
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query)
.onErrorResume(e -> {
log.error("获取签到统计数据失败", e);
return Mono.just(SignInStatistics.builder().statDate(statDate).build());
});
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder() .map(tuple -> StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.memberStatistics(tuple.getT1()) .memberStatistics(tuple.getT1())
.bookingStatistics(tuple.getT2()) .bookingStatistics(tuple.getT2())
.signInStatistics(tuple.getT3()) .signInStatistics(tuple.getT3())
.coachStatistics(tuple.getT4())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.build()); .build());
} }
@Override @Override
public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) { public Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取) // 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query); String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> { .flatMapMany(json -> {
try { try {
java.util.List<DataStatistics> stats = objectMapper.readValue(json, List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class)); objectMapper.getTypeFactory().constructCollectionType(List.class, DataStatistics.class));
return reactor.core.publisher.Flux.fromIterable(stats); return Flux.fromIterable(stats);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e); log.error("Failed to parse historical statistics from cache", e);
return reactor.core.publisher.Flux.empty(); return Flux.empty();
} }
}) })
.switchIfEmpty(reactor.core.publisher.Flux.empty()); .switchIfEmpty(buildLiveStatistics(query));
}
/**
* 缓存未命中时,从数据库实时构建统计数据
*/
private Flux<DataStatistics> buildLiveStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
String periodType = query.getPeriodType() != null ? query.getPeriodType() : "DAY";
LocalDateTime statDate = endTime;
Mono<Long> memberCountMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> bookingCountMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> signInCountMono = dataStatisticsDao.countSignIns(startTime, endTime);
return Mono.zip(memberCountMono, totalMembersMono, bookingCountMono, signInCountMono)
.flatMapMany(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long bookings = tuple.getT3() != null ? tuple.getT3() : 0L;
long signIns = tuple.getT4() != null ? tuple.getT4() : 0L;
List<DataStatistics> list = new ArrayList<>();
DataStatistics memberStat = DataStatistics.builder()
.statType(DataStatistics.StatType.MEMBER)
.periodType(periodType)
.statDate(statDate)
.count(totalMembers)
.extraData("新增" + newMembers + "")
.build();
list.add(memberStat);
DataStatistics bookingStat = DataStatistics.builder()
.statType(DataStatistics.StatType.BOOKING)
.periodType(periodType)
.statDate(statDate)
.count(bookings)
.extraData("新增" + bookings + "条预约")
.build();
list.add(bookingStat);
DataStatistics signInStat = DataStatistics.builder()
.statType(DataStatistics.StatType.SIGN_IN)
.periodType(periodType)
.statDate(statDate)
.count(signIns)
.extraData("新增" + signIns + "次签到")
.build();
list.add(signInStat);
return Flux.fromIterable(list);
});
} }
@Override @Override
@@ -487,16 +531,13 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周一
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(); return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月第一天
return today.withDayOfMonth(1).atStartOfDay(); return today.withDayOfMonth(1).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
return today.minusDays(29).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
return today.minusDays(89).atStartOfDay();
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
return today.withDayOfYear(1).atStartOfDay();
} else { } else {
// 日统计:当天零点
return today.atStartOfDay(); return today.atStartOfDay();
} }
} }
@@ -510,14 +551,13 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周日 23:59:59
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59); return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月最后一天 23:59:59
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
return LocalDateTime.now();
} else { } else {
// 日统计:当前时间
return LocalDateTime.now(); return LocalDateTime.now();
} }
} }
@@ -536,15 +576,6 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
startTime = date.withDayOfMonth(1).atStartOfDay(); startTime = date.withDayOfMonth(1).atStartOfDay();
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
startTime = date.minusDays(29).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
startTime = date.minusDays(89).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
startTime = date.withDayOfYear(1).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else { } else {
startTime = date.atStartOfDay(); startTime = date.atStartOfDay();
endTime = date.plusDays(1).atStartOfDay(); endTime = date.plusDays(1).atStartOfDay();
-9
View File
@@ -94,20 +94,11 @@
<version>3.5.3</version> <version>3.5.3</version>
</dependency> </dependency>
<!-- 阿里云OSS SDK --> <!-- 阿里云OSS SDK -->
<!--
<dependency> <dependency>
<groupId>com.aliyun.oss</groupId> <groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId> <artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version> <version>3.17.4</version>
</dependency> </dependency>
-->
<!-- 文件管理模块(统一文件存储) -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-file</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -0,0 +1,34 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Mono<BannerEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<BannerEntity> findAllByDeletedAtIsNull();
Flux<BannerEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE banner SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE banner SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -114,11 +114,4 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
@org.springframework.data.r2dbc.repository.Modifying @org.springframework.data.r2dbc.repository.Modifying
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = '3', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = '3', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToAbsent(Long id, java.time.LocalDateTime updatedAt); Mono<Integer> updateToAbsent(Long id, java.time.LocalDateTime updatedAt);
/**
* 批量更新某课程的预约状态(教练缺席场景)
*/
@org.springframework.data.r2dbc.repository.Modifying
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = :newStatus, updated_at = NOW() WHERE course_id = :courseId AND status = :oldStatus AND deleted_at IS NULL")
Mono<Integer> updateStatusByCourseId(Long courseId, String oldStatus, String newStatus);
} }
@@ -1,6 +1,5 @@
package cn.novalon.gym.manage.groupcourse.dao; package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -33,116 +32,79 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt); Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET current_members = current_members + :delta, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateCurrentMembers(Long id, Integer delta, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
// ---------- 教练相关 SQL 方法 ---------- @Modifying
@Query("UPDATE group_course SET deleted_at = NULL, status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NOT NULL")
Mono<Integer> restoreCourse(Long id, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET status = :status, actual_start_time = :actualStartTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> updateStartInfo(Long id, String status, LocalDateTime actualStartTime, LocalDateTime updatedAt); Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET status = :status, actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> updateEndInfo(Long id, String status, LocalDateTime actualEndTime, LocalDateTime updatedAt); Mono<Integer> renewRecurringCourses(LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '5', actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToCoachAbsent(Long id, LocalDateTime actualEndTime, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '6', actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToAutoEnded(Long id, LocalDateTime actualEndTime, LocalDateTime updatedAt);
/**
* 查询指定状态且开始时间早于指定时间的课程(用于缺席检查)
*/
default Flux<GroupCourseEntity> findByStatusAndStartTimeBefore(DatabaseClient databaseClient, String status, LocalDateTime time) {
return databaseClient.sql("SELECT * FROM group_course WHERE status = :status AND start_time < :time AND deleted_at IS NULL")
.bind("status", status)
.bind("time", time)
.map((row, meta) -> mapRowToEntity(row))
.all();
}
/**
* 查询指定状态集合且结束时间早于指定时间的课程(用于自动结课检查)
*/
default Flux<GroupCourseEntity> findByStatusInAndEndTimeBefore(DatabaseClient databaseClient, String[] statuses, LocalDateTime time) {
return databaseClient.sql("SELECT * FROM group_course WHERE status = ANY(:statuses::varchar[]) AND end_time < :time AND deleted_at IS NULL")
.bind("statuses", statuses)
.bind("time", time)
.map((row, meta) -> mapRowToEntity(row))
.all();
}
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType); Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
// ==================== 教练相关查询 ====================
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId);
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId, Sort sort);
@Query("SELECT COUNT(*) FROM group_course WHERE coach_id = :coachId AND status = :status AND deleted_at IS NULL")
Mono<Long> countByCoachIdAndStatus(Long coachId, String status);
@Modifying
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE coach_id = :coachId AND status != :excludeStatus AND deleted_at IS NULL")
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, String excludeStatus, LocalDateTime updatedAt);
/** /**
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL * 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/ */
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) { default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
"FROM group_course gc " +
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
"ON gc.id = b.course_id " +
"WHERE gc.deleted_at IS NULL");
List<String> conditions = new ArrayList<>(); List<String> conditions = new ArrayList<>();
// 默认不查询可预约团课(status = '0' 且未过期) // 默认不查询可预约团课(status = '0' 且未过期)
conditions.add("gc.status = '0'"); conditions.add("status = '0'");
conditions.add("gc.end_time > NOW()"); conditions.add("end_time > NOW()");
// 1. 团课名称模糊查询 // 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) { if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("gc.course_name ILIKE :courseName"); conditions.add("course_name ILIKE :courseName");
} }
// 2. 基于团课类型查询 // 2. 基于团课类型查询
if (query.getCourseType() != null) { if (query.getCourseType() != null) {
conditions.add("gc.course_type = :courseType"); conditions.add("course_type = :courseType");
} }
// 3. 基于日期时间段查询 // 3. 基于日期时间段查询
if (query.getStartDate() != null) { if (query.getStartDate() != null) {
conditions.add("gc.start_time >= :startDate"); conditions.add("start_time >= :startDate");
} }
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
conditions.add("gc.start_time <= :endDate"); conditions.add("start_time <= :endDate");
} }
// 4. 基于早晨/下午/夜晚时间段查询 // 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) { if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) { switch (query.getTimePeriod().toLowerCase()) {
case "morning": case "morning":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 6 AND EXTRACT(HOUR FROM gc.start_time) < 12"); conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break; break;
case "afternoon": case "afternoon":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 12 AND EXTRACT(HOUR FROM gc.start_time) < 18"); conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break; break;
case "evening": case "evening":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 18 AND EXTRACT(HOUR FROM gc.start_time) < 24"); conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break; break;
default: default:
break; break;
} }
} }
// 5. 常态化团课筛选
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序 // 5. 价格排序 / 6. 剩余名额最多排序
@@ -154,20 +116,20 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
List<String> orderClauses = new ArrayList<>(); List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) { if (hasRemainingMost) {
orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC"); orderClauses.add(" (max_members - current_members) DESC");
} }
if (hasPriceSort) { if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) { if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" gc.stored_value_amount ASC"); orderClauses.add(" stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) { } else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" gc.stored_value_amount DESC"); orderClauses.add(" stored_value_amount DESC");
} }
} }
sql.append(String.join(",", orderClauses)); sql.append(String.join(",", orderClauses));
} else { } else {
sql.append(" ORDER BY gc.start_time ASC"); sql.append(" ORDER BY start_time ASC");
} }
// 分页 // 分页
@@ -192,10 +154,36 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
spec = spec.bind("limit", size); spec = spec.bind("limit", size);
spec = spec.bind("offset", offset); spec = spec.bind("offset", offset);
return spec.map((row, meta) -> mapRowToEntity(row)).all(); return spec.map((row, meta) -> {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
entity.setCurrentMembers(row.get("current_members", Integer.class));
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
return entity;
}).all();
} }
/** /**
@@ -236,6 +224,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString()); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
@@ -252,126 +244,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
return spec.map((row, meta) -> row.get(0, Long.class)).one(); spec = spec.bind("isRecurring", query.getIsRecurring());
}
/**
* 分页查询团课(支持 keyword 和 status 过滤)
*/
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder(
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
"FROM group_course gc " +
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
"ON gc.id = b.course_id " +
"WHERE gc.deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("gc.course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("gc.status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
sql.append(" ORDER BY gc.").append(sort).append(" ").append(order);
int size = pageRequest.getSize();
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = pageRequest.getPage() * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
spec = spec.bind("status", pageRequest.getStatus());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> mapRowToEntity(row)).all();
}
/**
* 分页查询团课总数(支持 keyword 和 status 过滤)
*/
default Mono<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
spec = spec.bind("status", pageRequest.getStatus());
} }
return spec.map((row, meta) -> row.get(0, Long.class)).one(); return spec.map((row, meta) -> row.get(0, Long.class)).one();
} }
/**
* 统计单门课程的有效预约人数(status='0'
*/
default Mono<Long> countValidBookings(DatabaseClient databaseClient, Long courseId) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE course_id = :id AND status = '0'")
.bind("id", courseId)
.map((row, meta) -> row.get(0, Long.class))
.one()
.defaultIfEmpty(0L);
}
/**
* 将数据库行映射为 GroupCourseEntity
*/
private GroupCourseEntity mapRowToEntity(io.r2dbc.spi.Row row) {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
Integer cm = row.get("current_members", Integer.class);
entity.setCurrentMembers(cm != null ? cm : 0);
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setActualStartTime(row.get("actual_start_time", LocalDateTime.class));
entity.setActualEndTime(row.get("actual_end_time", LocalDateTime.class));
entity.setQrCodePath(row.get("qr_code_path", String.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
return entity;
}
} }
@@ -29,4 +29,8 @@ public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntit
@Modifying @Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_type SET type_name = :typeName, base_difficulty = :baseDifficulty, description = :description, category = :category, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateFields(Long id, String typeName, Integer baseDifficulty, String description, String category, LocalDateTime updatedAt);
} }
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class Banner extends BaseDomain {
@Schema(description = "背景图URL", example = "https://example.com/banner.jpg")
private String imageUrl;
@Schema(description = "主标题", example = "突破自我")
private String title;
@Schema(description = "副标题", example = "超越极限")
private String subtitle;
@Schema(description = "简介", example = "科学训练 · 遇见更好的自己")
private String description;
@Schema(description = "排序(数值越大越靠前)", example = "10")
private Integer sortOrder;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -40,14 +40,6 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "课程状态", example = "0") @Schema(description = "课程状态", example = "0")
private Long status; private Long status;
//实际开课时间
@Schema(description = "实际开课时间")
private LocalDateTime actualStartTime;
//实际结课时间
@Schema(description = "实际结课时间")
private LocalDateTime actualEndTime;
//上课地点 //上课地点
@Schema(description = "上课地点", example = "龙泉驿区幸福路") @Schema(description = "上课地点", example = "龙泉驿区幸福路")
private String location; private String location;
@@ -68,6 +60,10 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png") @Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Schema(description = "是否常态化团课", example = "true")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -132,22 +128,6 @@ public class GroupCourse extends BaseDomain{
this.status = status; this.status = status;
} }
public LocalDateTime getActualStartTime() {
return actualStartTime;
}
public void setActualStartTime(LocalDateTime actualStartTime) {
this.actualStartTime = actualStartTime;
}
public LocalDateTime getActualEndTime() {
return actualEndTime;
}
public void setActualEndTime(LocalDateTime actualEndTime) {
this.actualEndTime = actualEndTime;
}
public String getLocation() { public String getLocation() {
return location; return location;
} }
@@ -187,4 +167,12 @@ public class GroupCourse extends BaseDomain{
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -53,6 +53,10 @@ public class GroupCourseBooking extends BaseDomain {
@Schema(description = "上课地点", example = "健身房A区") @Schema(description = "上课地点", example = "健身房A区")
private String location; private String location;
//封面图URL(非DB字段,由 Service 从 GroupCourse 填充)
@Schema(description = "封面图URL", example = "https://example.com/cover.jpg")
private String coverImage;
public Long getCourseId() { public Long getCourseId() {
return courseId; return courseId;
} }
@@ -132,4 +136,12 @@ public class GroupCourseBooking extends BaseDomain {
public void setLocation(String location) { public void setLocation(String location) {
this.location = location; this.location = location;
} }
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
} }
@@ -21,9 +21,6 @@ public class GroupCourseDetail extends BaseDomain {
@Schema(description = "教练ID", example = "1") @Schema(description = "教练ID", example = "1")
private Long coachId; private Long coachId;
@Schema(description = "教练名称", example = "张教练")
private String coachName;
@Schema(description = "课程类型ID", example = "1") @Schema(description = "课程类型ID", example = "1")
private Long courseType; private Long courseType;
@@ -102,14 +99,6 @@ public class GroupCourseDetail extends BaseDomain {
this.coachId = coachId; this.coachId = coachId;
} }
public String getCoachName() {
return coachName;
}
public void setCoachName(String coachName) {
this.coachName = coachName;
}
public Long getCourseType() { public Long getCourseType() {
return courseType; return courseType;
} }
@@ -40,6 +40,9 @@ public class GroupCourseQueryDto {
@Schema(description = "每页大小", example = "10") @Schema(description = "每页大小", example = "10")
private Integer size = 10; private Integer size = 10;
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
private Boolean isRecurring;
// ===== Getters and Setters ===== // ===== Getters and Setters =====
public String getCourseName() { public String getCourseName() {
@@ -113,4 +116,12 @@ public class GroupCourseQueryDto {
public void setSize(Integer size) { public void setSize(Integer size) {
this.size = size; this.size = size;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -0,0 +1,45 @@
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;
@Table("banner")
public class BannerEntity extends BaseEntity {
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("description")
private String description;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -38,15 +38,7 @@ public class GroupCourseEntity extends BaseEntity {
@Column("current_members") @Column("current_members")
private Integer currentMembers; private Integer currentMembers;
//实际开课时间 //课程状态:0-正常,1-已取消,2-已结束
@Column("actual_start_time")
private LocalDateTime actualStartTime;
//实际结课时间
@Column("actual_end_time")
private LocalDateTime actualEndTime;
//课程状态:0-正常,1-已取消,2-已结束,3-进行中,5-教练缺席,6-自动结束,7-教练迟到
@Column("status") @Column("status")
private Long status; private Long status;
@@ -70,6 +62,10 @@ public class GroupCourseEntity extends BaseEntity {
@Column("qr_code_path") @Column("qr_code_path")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Column("is_recurring")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -166,22 +162,6 @@ public class GroupCourseEntity extends BaseEntity {
this.storedValueAmount = storedValueAmount; this.storedValueAmount = storedValueAmount;
} }
public LocalDateTime getActualStartTime() {
return actualStartTime;
}
public void setActualStartTime(LocalDateTime actualStartTime) {
this.actualStartTime = actualStartTime;
}
public LocalDateTime getActualEndTime() {
return actualEndTime;
}
public void setActualEndTime(LocalDateTime actualEndTime) {
this.actualEndTime = actualEndTime;
}
public String getQrCodePath() { public String getQrCodePath() {
return qrCodePath; return qrCodePath;
} }
@@ -189,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity {
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -11,10 +11,7 @@ public enum CourseStatus {
NORMAL(0L, "正常"), NORMAL(0L, "正常"),
CANCELLED(1L, "已取消"), CANCELLED(1L, "已取消"),
ENDED(2L, "已结束"), ENDED(2L, "已结束"),
IN_PROGRESS(3L, "进行中"), IN_PROGRESS(3L, "进行中");
COACH_ABSENT(5L, "教练缺席"),
AUTO_ENDED(6L, "自动结束"),
COACH_LATE(7L, "教练迟到");
private final Long value; private final Long value;
private final String desc; private final String desc;
@@ -0,0 +1,224 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "轮播图管理", description = "轮播图相关操作")
public class BannerHandler {
private final IBannerService bannerService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public BannerHandler(IBannerService bannerService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.bannerService = bannerService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序")
public Mono<ServerResponse> getAllBanners(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("sortOrder");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(bannerService.findAll(sortBy, sortOrder), Banner.class);
}
@Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表")
public Mono<ServerResponse> getAllActiveBanners(ServerRequest request) {
return ServerResponse.ok()
.body(bannerService.findAllActive(), Banner.class);
}
@Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情")
public Mono<ServerResponse> getBannerById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.findById(id)
.flatMap(banner -> ServerResponse.ok().bodyValue(banner))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建轮播图", description = "创建新的轮播图记录")
public Mono<ServerResponse> createBanner(ServerRequest request) {
return request.bodyToMono(Banner.class)
.flatMap(banner -> {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "背景图不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "主标题不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return bannerService.create(banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新轮播图", description = "更新指定轮播图信息,需验证管理员密码")
public Mono<ServerResponse> updateBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(Banner.class)
.flatMap(banner -> bannerService.update(id, banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除轮播图", description = "删除指定轮播图(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "启用轮播图", description = "启用指定轮播图")
public Mono<ServerResponse> enableBanner(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "禁用轮播图", description = "禁用指定轮播图,需验证管理员密码")
public Mono<ServerResponse> disableBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.impl.GroupCourseRedisService;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
@@ -32,9 +34,11 @@ import java.util.List;
public class BookingSagaHandler { public class BookingSagaHandler {
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
private final IGroupCourseRepository courseRepository;
private final IMemberCardRecordService memberCardRecordService; private final IMemberCardRecordService memberCardRecordService;
private final IMemberStoredCardService memberStoredCardService; private final IMemberStoredCardService memberStoredCardService;
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final GroupCourseRedisService redisService;
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
@@ -68,6 +72,24 @@ public class BookingSagaHandler {
steps.add(step2); steps.add(step2);
rollbackSteps.add(0, step2); rollbackSteps.add(0, step2);
// 步骤3:更新课程当前人数
SagaStep step3 = new SagaStep(
"更新课程当前人数",
incrementCourseCurrentMembers(booking.getCourseId()),
Mono.defer(() -> decrementCourseCurrentMembers(booking.getCourseId()))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
incrementRedisBookingCount(booking.getCourseId()),
Mono.defer(() -> decrementRedisBookingCount(booking.getCourseId()))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(Mono.just(booking)); .then(Mono.just(booking));
} }
@@ -203,6 +225,24 @@ public class BookingSagaHandler {
steps.add(step2); steps.add(step2);
rollbackSteps.add(0, step2); rollbackSteps.add(0, step2);
// 步骤3:减少课程当前人数
SagaStep step3 = new SagaStep(
"减少课程当前人数",
decrementCourseCurrentMembers(courseId),
Mono.defer(() -> incrementCourseCurrentMembers(courseId))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
decrementRedisBookingCount(courseId),
Mono.defer(() -> incrementRedisBookingCount(courseId))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(bookingRepository.findById(bookingId)); .then(bookingRepository.findById(bookingId));
} }
@@ -261,6 +301,22 @@ public class BookingSagaHandler {
}); });
} }
private Mono<Void> incrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, 1).then();
}
private Mono<Void> decrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, -1).then();
}
private Mono<Void> incrementRedisBookingCount(Long courseId) {
return redisService.incrementBookingCount(courseId).then();
}
private Mono<Void> decrementRedisBookingCount(Long courseId) {
return redisService.decrementBookingCount(courseId).then();
}
private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) { private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) {
List<SagaStep> completedSteps = new ArrayList<>(); List<SagaStep> completedSteps = new ArrayList<>();
return executeStep(steps, 0, completedSteps); return executeStep(steps, 0, completedSteps);
@@ -0,0 +1,120 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
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.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 通用文件上传处理器
*/
@Slf4j
@Component
@Tag(name = "文件上传", description = "通用文件上传到阿里云OSS")
public class CommonUploadHandler {
@Operation(summary = "上传图片文件", description = "上传图片到阿里云OSS,返回ossKey和预签名URL")
public Mono<ServerResponse> uploadImage(ServerRequest request) {
return request.multipartData()
.flatMap(multiValueMap -> {
List<Part> fileParts = multiValueMap.get("file");
if (fileParts == null || fileParts.isEmpty()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "未选择文件"));
}
Part part = fileParts.get(0);
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "文件格式不正确"));
}
String originalFilename = filePart.filename();
String ext = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
ext = originalFilename.substring(dotIndex);
}
String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;
Path tempFile = null;
try {
tempFile = Files.createTempFile("upload-", newFileName);
} catch (Exception e) {
return Mono.error(new RuntimeException("创建临时文件失败", e));
}
Path finalTempFile = tempFile;
return filePart.transferTo(finalTempFile)
.then(Mono.defer(() -> {
try {
String ossKey;
try (InputStream inputStream = Files.newInputStream(finalTempFile)) {
ossKey = OSSUtil.uploadCoverToOSS(inputStream, newFileName);
}
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"message", "上传成功",
"data", Map.of(
"ossKey", ossKey,
"presignedUrl", presignedUrl,
"fileName", originalFilename
)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "上传失败: " + e.getMessage()));
} finally {
try {
Files.deleteIfExists(finalTempFile);
} catch (Exception ignored) {
}
}
}));
});
}
@Operation(summary = "获取OSS预签名URL", description = "根据ossKey生成临时访问URL,有效期5分钟")
public Mono<ServerResponse> presignUrl(ServerRequest request) {
String ossKey = request.queryParam("key").orElse(null);
if (ossKey == null || ossKey.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "参数 key 不能为空"));
}
try {
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"data", Map.of("presignedUrl", presignedUrl)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "生成预签名URL失败: " + e.getMessage()));
}
}
}
@@ -1,6 +1,5 @@
package cn.novalon.gym.manage.groupcourse.handler; package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService; import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -30,13 +29,6 @@ public class CourseLabelHandler {
.body(courseLabelService.findAll(), CourseLabel.class); .body(courseLabelService.findAll(), CourseLabel.class);
} }
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
}
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情") @Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) { public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
@@ -154,17 +146,24 @@ public class CourseLabelHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
@SuppressWarnings("unchecked") Object labelIdsObj = body.get("labelIds");
List<?> rawIds = (List<?>) body.get("labelIds");
if (!(labelIdsObj instanceof List)) {
if (rawIds == null || rawIds.isEmpty()) {
Map<String, Object> error = new HashMap<>(); Map<String, Object> error = new HashMap<>();
error.put("success", false); error.put("success", false);
error.put("message", "labelIds不能为空"); error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error); return ServerResponse.badRequest().bodyValue(error);
} }
List<Long> labelIds = rawIds.stream() List<?> rawList = (List<?>) labelIdsObj;
if (rawList.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawList.stream()
.map(id -> Long.valueOf(String.valueOf(id))) .map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -23,9 +24,12 @@ import java.util.Map;
public class GroupCourseBookingHandler { public class GroupCourseBookingHandler {
private final IGroupCourseBookingService bookingService; private final IGroupCourseBookingService bookingService;
private final IMemberStoredCardService memberStoredCardService;
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) { public GroupCourseBookingHandler(IGroupCourseBookingService bookingService,
IMemberStoredCardService memberStoredCardService) {
this.bookingService = bookingService; this.bookingService = bookingService;
this.memberStoredCardService = memberStoredCardService;
} }
/** /**
@@ -42,23 +46,38 @@ public class GroupCourseBookingHandler {
if (body.get("memberId") == null) { if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID"); return buildErrorResponse("请提供会员ID");
} }
if (body.get("memberCardRecordId") == null) {
return buildErrorResponse("请提供会员卡记录ID");
}
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
return buildErrorResponse("请输入支付密码");
}
Long courseId = toLong(body.get("courseId"), "courseId"); Long courseId = toLong(body.get("courseId"), "courseId");
Long memberId = toLong(body.get("memberId"), "memberId"); Long memberId = toLong(body.get("memberId"), "memberId");
Long memberCardRecordId = toLong(body.get("memberCardRecordId"), "memberCardRecordId");
String payPassword = body.get("payPassword").toString();
return bookingService.bookCourse(courseId, memberId) // 验证支付密码
.flatMap(booking -> { return memberStoredCardService.verifyPayPassword(memberId, payPassword)
Map<String, Object> response = new HashMap<>(); .flatMap(passwordValid -> {
response.put("success", true); if (!passwordValid) {
response.put("message", "预约成功"); return buildErrorResponse("支付密码错误");
response.put("data", booking); }
return ServerResponse.ok().bodyValue(response); return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
}) .flatMap(booking -> {
.onErrorResume(error -> { Map<String, Object> response = new HashMap<>();
Map<String, Object> response = new HashMap<>(); response.put("success", true);
response.put("success", false); response.put("message", "预约成功");
response.put("message", error.getMessage()); response.put("data", booking);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
}); });
} }
@@ -73,20 +92,31 @@ public class GroupCourseBookingHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
Long memberId = toLong(body.get("memberId"), "memberId"); Long memberId = toLong(body.get("memberId"), "memberId");
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
return buildErrorResponse("请输入支付密码");
}
String payPassword = body.get("payPassword").toString();
return bookingService.cancelBooking(bookingId, memberId) // 验证支付密码
.flatMap(booking -> { return memberStoredCardService.verifyPayPassword(memberId, payPassword)
Map<String, Object> response = new HashMap<>(); .flatMap(passwordValid -> {
response.put("success", true); if (!passwordValid) {
response.put("message", "取消成功"); return buildErrorResponse("支付密码错误");
response.put("data", booking); }
return ServerResponse.ok().bodyValue(response); return bookingService.cancelBooking(bookingId, memberId)
}) .flatMap(booking -> {
.onErrorResume(error -> { Map<String, Object> response = new HashMap<>();
Map<String, Object> response = new HashMap<>(); response.put("success", true);
response.put("success", false); response.put("message", "取消成功");
response.put("message", error.getMessage()); response.put("data", booking);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
}); });
} }
@@ -147,4 +177,31 @@ public class GroupCourseBookingHandler {
response.put("message", message); response.put("message", message);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
/**
* 扫码签到(二维码扫一扫签到)
* 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席)
*/
@Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席")
public Mono<ServerResponse> qrSignIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID");
}
Long memberId = toLong(body.get("memberId"), "memberId");
return bookingService.qrSignIn(courseId, memberId)
.flatMap(booking -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "签到成功");
response.put("data", booking);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> buildErrorResponse(error.getMessage()));
});
}
} }
@@ -7,48 +7,57 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.slf4j.Logger; import org.springframework.http.HttpStatus;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDateTime;
@Component @Component
@Tag(name="团课管理",description = "团课相关操作") @Tag(name="团课管理",description = "团课相关操作")
public class GroupCourseHandler { public class GroupCourseHandler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class);
private final IGroupCourseService groupCourseService; private final IGroupCourseService groupCourseService;
private final Validator validator; private final Validator validator;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseHandler(IGroupCourseService groupCourseService, public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator, Validator validator,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper){ ObjectMapper objectMapper,
ISysUserService sysUserService,
AuthUtil authUtil){
this.groupCourseService = groupCourseService; this.groupCourseService = groupCourseService;
this.validator = validator; this.validator = validator;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表(含教练昵称)") @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){ public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false")); boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return groupCourseService.findAllAsVO(includeDeleted) return ServerResponse.ok()
.collectList() .body(groupCourseService.findAll(includeDeleted), GroupCourse.class);
.flatMap(list -> ServerResponse.ok().bodyValue(list));
} }
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表") @Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
@@ -72,7 +81,7 @@ public class GroupCourseHandler {
pageRequest.setOrder("asc"); pageRequest.setOrder("asc");
} }
return groupCourseService.findByPageAsVO(pageRequest, includeDeleted) return groupCourseService.findByPage(pageRequest, includeDeleted)
.flatMap(response -> ServerResponse.ok().bodyValue(response)); .flatMap(response -> ServerResponse.ok().bodyValue(response));
}); });
} }
@@ -121,25 +130,43 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "更新团课", description = "更新指定团课信息") @Operation(summary = "更新团课", description = "更新指定团课信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) { public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourse.class)
.flatMap(groupCourse -> { if (adminPassword == null || adminPassword.isBlank()) {
return groupCourseService.update(id, groupCourse) Map<String, Object> error = new HashMap<>();
.flatMap(course -> { error.put("success", false);
Map<String, Object> response = new HashMap<>(); error.put("message", "管理员密码不能为空");
response.put("success", true); return ServerResponse.badRequest().bodyValue(error);
response.put("message", "团课更新成功"); }
response.put("data", course);
return ServerResponse.ok().bodyValue(response); return sysUserService.verifyPassword(adminId, adminPassword)
}) .flatMap(valid -> {
.onErrorResume(error -> { if (!valid) {
Map<String, Object> response = new HashMap<>(); Map<String, Object> error = new HashMap<>();
response.put("success", false); error.put("success", false);
response.put("message", error.getMessage()); error.put("message", "管理员密码错误");
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourse.class)
.flatMap(groupCourse -> {
return groupCourseService.update(id, groupCourse)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课更新成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
}); });
} }
@@ -204,22 +231,78 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "删除团课", description = "删除指定团课(软删除)") @Operation(summary = "删除团课", description = "删除指定团课(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) { public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return groupCourseService.delete(id)
.then(Mono.defer(() -> { if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> response = new HashMap<>(); Map<String, Object> error = new HashMap<>();
response.put("success", true); error.put("success", false);
response.put("message", "团课删除成功"); error.put("message", "管理员密码不能为空");
return ServerResponse.ok().bodyValue(response); return ServerResponse.badRequest().bodyValue(error);
})) }
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); return sysUserService.verifyPassword(adminId, adminPassword)
response.put("success", false); .flatMap(valid -> {
response.put("message", error.getMessage()); if (!valid) {
return ServerResponse.badRequest().bodyValue(response); Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "恢复已删除团课", description = "将已删除的团课恢复为已取消状态,需验证管理员密码")
public Mono<ServerResponse> restoreGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.restore(id)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课恢复成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
} }
@@ -297,49 +380,43 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课") @Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)")
public Mono<ServerResponse> checkCoachConflict(ServerRequest request) { public Mono<ServerResponse> getCourseQRCode(ServerRequest request) {
return request.bodyToMono(Map.class) Long courseId = Long.valueOf(request.pathVariable("id"));
.flatMap(body -> {
if (body == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "请求体不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
Object coachIdObj = body.get("coachId"); return groupCourseService.findById(courseId)
Object startTimeStr = body.get("startTime"); .flatMap(course -> {
Object endTimeStr = body.get("endTime"); return Mono.fromCallable(() -> {
Object excludeCourseIdObj = body.get("excludeCourseId"); String qrContent = "{\"courseId\":" + course.getId()
+ ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}";
if (coachIdObj == null || startTimeStr == null || endTimeStr == null) { QRCodeWriter qrCodeWriter = new QRCodeWriter();
Map<String, Object> error = new HashMap<>(); BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300);
error.put("success", false);
error.put("message", "coachId、startTime、endTime 不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
Long coachId = Long.valueOf(String.valueOf(coachIdObj)); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr)); MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);
LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr)); byte[] pngBytes = outputStream.toByteArray();
Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null; String base64 = Base64.getEncoder().encodeToString(pngBytes);
return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId) Map<String, Object> result = new HashMap<>();
.flatMap(conflicts -> { result.put("base64", "data:image/png;base64," + base64);
Map<String, Object> response = new HashMap<>(); result.put("courseId", course.getId());
response.put("success", true); result.put("courseName", course.getCourseName());
response.put("hasConflict", !conflicts.isEmpty()); result.put("width", 300);
response.put("conflicts", conflicts); result.put("height", 300);
return ServerResponse.ok().bodyValue(response); return result;
}); }).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
}) })
.onErrorResume(error -> { .flatMap(result -> ServerResponse.ok().bodyValue(result))
logger.warn("检查教练时间冲突失败: {}", error.getMessage()); .switchIfEmpty(ServerResponse.notFound().build());
Map<String, Object> response = new HashMap<>(); }
response.put("success", false);
response.put("message", "检查失败: " + error.getMessage()); private String escapeJson(String s) {
return ServerResponse.badRequest().bodyValue(response); if (s == null) return "";
}); return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
} }
} }
@@ -2,8 +2,11 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -17,9 +20,15 @@ import java.util.Map;
public class GroupCourseRecommendHandler { public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService; private final IGroupCourseRecommendService recommendService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) { public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.recommendService = recommendService; this.recommendService = recommendService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序") @Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
@@ -80,20 +89,73 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息") @Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) { public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourseRecommend.class) if (adminPassword == null || adminPassword.isBlank()) {
.flatMap(recommend -> { Map<String, Object> error = new HashMap<>();
return recommendService.update(id, recommend) error.put("success", false);
.flatMap(r -> { error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> recommendService.update(id, recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
response.put("message", "团课推荐更新成功"); response.put("message", "团课推荐删除成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response); return ServerResponse.ok().bodyValue(response);
}) }))
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
@@ -103,25 +165,6 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐") @Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
public Mono<ServerResponse> enableRecommendation(ServerRequest request) { public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
@@ -142,23 +185,41 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐") @Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) { public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return recommendService.disable(id) if (adminPassword == null || adminPassword.isBlank()) {
.flatMap(r -> { Map<String, Object> error = new HashMap<>();
Map<String, Object> response = new HashMap<>(); error.put("success", false);
response.put("success", true); error.put("message", "管理员密码不能为空");
response.put("message", "团课推荐禁用成功"); return ServerResponse.badRequest().bodyValue(error);
response.put("data", r); }
return ServerResponse.ok().bodyValue(response);
}) return sysUserService.verifyPassword(adminId, adminPassword)
.onErrorResume(error -> { .flatMap(valid -> {
Map<String, Object> response = new HashMap<>(); if (!valid) {
response.put("success", false); Map<String, Object> error = new HashMap<>();
response.put("message", error.getMessage()); error.put("success", false);
return ServerResponse.badRequest().bodyValue(response); error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
} }
} }
@@ -1,10 +1,12 @@
package cn.novalon.gym.manage.groupcourse.handler; package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -18,9 +20,15 @@ import java.util.Map;
public class GroupCourseTypeHandler { public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService; private final IGroupCourseTypeService groupCourseTypeService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) { public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.groupCourseTypeService = groupCourseTypeService; this.groupCourseTypeService = groupCourseTypeService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表") @Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
@@ -93,21 +101,76 @@ public class GroupCourseTypeHandler {
}); });
} }
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息") @Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) { public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> { if (adminPassword == null || adminPassword.isBlank()) {
groupCourseType.setId(id); Map<String, Object> error = new HashMap<>();
return groupCourseTypeService.update(id, groupCourseType) error.put("success", false);
.flatMap(type -> { error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
groupCourseType.setId(id);
return groupCourseTypeService.update(id, groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型更新成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
});
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除),需验证管理员密码,且该类型不能被任何团课引用")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
response.put("message", "团课类型更新成功"); response.put("message", "团课类型删除成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response); return ServerResponse.ok().bodyValue(response);
}) }))
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
@@ -116,31 +179,4 @@ public class GroupCourseTypeHandler {
}); });
}); });
} }
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response))
);
}
}
@@ -1,6 +1,5 @@
package cn.novalon.gym.manage.groupcourse.initializer; package cn.novalon.gym.manage.groupcourse.initializer;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil; import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
@@ -11,12 +10,15 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 项目启动时补全缺失的团课二维码 * 项目启动时补全缺失的团课二维码
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并通过统一文件服务保存 * 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS
*/ */
@Component @Component
public class QrCodeInitializer implements CommandLineRunner { public class QrCodeInitializer implements CommandLineRunner {
@@ -25,14 +27,11 @@ public class QrCodeInitializer implements CommandLineRunner {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysFileService fileService;
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository, public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
ObjectMapper objectMapper, ObjectMapper objectMapper) {
ISysFileService fileService) {
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.fileService = fileService;
} }
@Override @Override
@@ -59,22 +58,20 @@ public class QrCodeInitializer implements CommandLineRunner {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码字节数组,通过统一文件服务保存 // 生成二维码并上传到阿里云OSS
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent)) String uuid = UUID.randomUUID().toString().replace("-", "");
.flatMap(qrCodeBytes -> { String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = "qrcode_" + course.getId() + ".png"; String fileName = "qr_" + uuid + "_" + timestamp + ".png";
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system"); String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName);
})
.flatMap(sysFile -> { course.setQrCodePath(ossUrl);
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
course.setQrCodePath(qrCodeUrl); // 更新数据库
return groupCourseRepository.update(course)
return groupCourseRepository.update(course) .doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}",
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, url={}", updated.getId(), updated.getCourseName(), ossUrl))
updated.getId(), updated.getCourseName(), qrCodeUrl)) .doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}", course.getId(), course.getCourseName(), error.getMessage()));
course.getId(), course.getCourseName(), error.getMessage()));
});
} catch (Exception e) { } catch (Exception e) {
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}", logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), e.getMessage(), e); course.getId(), course.getCourseName(), e.getMessage(), e);
@@ -0,0 +1,24 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerRepository {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> save(Banner banner);
Mono<Banner> update(Banner banner);
Mono<Void> deleteById(Long id);
Mono<Banner> updateActiveStatus(Long id, Boolean isActive);
}
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.repository; package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -31,6 +29,4 @@ public interface ICourseLabelRepository {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -27,14 +27,11 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> restoreById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType); Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query); Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
// ==================== 教练相关 ====================
Flux<GroupCourse> findByCoachId(Long coachId);
Flux<GroupCourse> findByCoachId(Long coachId, Sort sort);
Mono<Long> countByCoachIdAndStatus(Long coachId, Long status);
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus);
} }
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.repository; package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -27,6 +25,4 @@ public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> update(GroupCourseType groupCourseType); Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -0,0 +1,113 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.BannerDao;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.bannerDao = bannerDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll() {
return bannerDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return bannerDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAllActive() {
return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder"))
.map(this::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return bannerDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<Banner> update(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(banner.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return bannerDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Banner> updateActiveStatus(Long id, Boolean isActive) {
return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private Banner toDomain(BannerEntity entity) {
if (entity == null) return null;
Banner banner = new Banner();
BeanUtil.copyProperties(entity, banner);
return banner;
}
private BannerEntity toEntity(Banner domain) {
if (domain == null) return null;
BannerEntity entity = new BannerEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao; import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao; import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
@@ -11,17 +9,12 @@ import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository; import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Repository @Repository
@@ -33,14 +26,12 @@ public class CourseLabelRepository implements ICourseLabelRepository {
private final CourseLabelDao courseLabelDao; private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao; private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter; private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao, public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) { GroupCourseConverter converter) {
this.courseLabelDao = courseLabelDao; this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao; this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -167,41 +158,4 @@ public class CourseLabelRepository implements ICourseLabelRepository {
} }
return entity; return entity;
} }
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<CourseLabel> list = tuple.getT1().stream()
.map(this::toCourseLabel)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
} }
@@ -97,19 +97,37 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) { public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
int size = pageRequest.getSize(); int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest) Sort sortObj = Sort.unsorted();
.flatMap(total -> { if (sort != null && !sort.isEmpty()) {
if (total == 0) { sortObj = Sort.by(Sort.Direction.fromString(order), sort);
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size)); }
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
.collectList()
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
.map(tuple -> {
List<GroupCourseEntity> allEntities = tuple.getT1();
long total = tuple.getT2();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, allEntities.size());
List<GroupCourse> courseList;
if (fromIndex < allEntities.size()) {
courseList = allEntities.subList(fromIndex, toIndex).stream()
.map(groupCourseConverter::toDomain)
.toList();
} else {
courseList = List.of();
} }
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
.map(groupCourseConverter::toDomain) int totalPages = (int) Math.ceil((double) total / size);
.collectList() return new PageResponse<>(courseList, totalPages, total, page, size);
.map(courseList -> {
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(courseList, totalPages, total, page, size);
});
}); });
} }
@@ -119,6 +137,8 @@ public class GroupCourseRepository implements IGroupCourseRepository {
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setStatus(0L); entity.setStatus(0L);
entity.setCurrentMembers(0);
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -128,6 +148,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<GroupCourse> update(GroupCourse groupCourse) { public Mono<GroupCourse> update(GroupCourse groupCourse) {
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse); GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return r2dbcEntityTemplate.update(entity) return r2dbcEntityTemplate.update(entity)
.then(findByIdAndDeletedAtIsNull(groupCourse.getId())); .then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
@@ -150,6 +171,28 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then(); .then();
} }
@Override
public Mono<GroupCourse> restoreById(Long id) {
return groupCourseDao.restoreCourse(id, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.error(new RuntimeException("团课恢复失败,可能该课程未被删除"));
});
}
@Override
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.empty();
});
}
@Override @Override
public Flux<GroupCourse> findByCourseType(Long courseType) { public Flux<GroupCourse> findByCourseType(Long courseType) {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType) return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
@@ -178,28 +221,4 @@ public class GroupCourseRepository implements IGroupCourseRepository {
}); });
}); });
} }
// ==================== 教练相关 ====================
@Override
public Flux<GroupCourse> findByCoachId(Long coachId) {
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId)
.map(groupCourseConverter::toDomain);
}
@Override
public Flux<GroupCourse> findByCoachId(Long coachId, Sort sort) {
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId, sort)
.map(groupCourseConverter::toDomain);
}
@Override
public Mono<Long> countByCoachIdAndStatus(Long coachId, Long status) {
return groupCourseDao.countByCoachIdAndStatus(coachId, String.valueOf(status));
}
@Override
public Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus) {
return groupCourseDao.cancelCoursesByCoachIdExceptStatus(coachId, String.valueOf(excludeStatus), LocalDateTime.now());
}
} }
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao; import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
@@ -9,18 +7,12 @@ import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository @Repository
@Transactional @Transactional
@@ -30,13 +22,10 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private final GroupCourseTypeDao groupCourseTypeDao; private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter; private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter, public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.groupCourseTypeDao = groupCourseTypeDao; this.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -116,21 +105,20 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()) return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> { .flatMap(existing -> {
existing.markNotNew(); String typeName = groupCourseType.getTypeName() != null
if (groupCourseType.getTypeName() != null) { ? groupCourseType.getTypeName() : existing.getTypeName();
existing.setTypeName(groupCourseType.getTypeName()); Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null
} ? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty();
if (groupCourseType.getBaseDifficulty() != null) { String description = groupCourseType.getDescription() != null
existing.setBaseDifficulty(groupCourseType.getBaseDifficulty()); ? groupCourseType.getDescription() : existing.getDescription();
} String category = groupCourseType.getCategory() != null
if (groupCourseType.getDescription() != null) { ? groupCourseType.getCategory() : existing.getCategory();
existing.setDescription(groupCourseType.getDescription()); LocalDateTime now = LocalDateTime.now();
}
if (groupCourseType.getCategory() != null) { return groupCourseTypeDao.updateFields(
existing.setCategory(groupCourseType.getCategory()); groupCourseType.getId(), typeName, baseDifficulty,
} description, category, now)
existing.setUpdatedAt(LocalDateTime.now()); .then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()));
return groupCourseTypeDao.save(existing);
}) })
.map(converter::toGroupCourseType); .map(converter::toGroupCourseType);
} }
@@ -140,46 +128,4 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now()) return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then(); .then();
} }
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String order = pageRequest.getOrder();
String sort = pageRequest.getSort();
String keyword = pageRequest.getKeyword();
String category = pageRequest.getCategory();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
// Build dynamic criteria
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
}
if (category != null && !category.isEmpty()) {
criteriaList.add(Criteria.where("category").is(category));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<GroupCourseType> list = tuple.getT1().stream()
.map(converter::toGroupCourseType)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
} }
@@ -0,0 +1,46 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 团课过期状态自动更新定时任务
*
* 功能:定期检查已过期的团课(end_time <= NOW()),自动将 status 从 0 更新为 2(已结束)
*
* @date 2026-06-24
*/
@Component
public class GroupCourseExpireScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseExpireScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseExpireScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已过期但状态仍为 0 的团课标记为已结束(status = 2)
*/
@Scheduled(fixedRate = 60000)
public void completeExpiredCourses() {
logger.debug("定时任务开始检查已过期团课,更新状态为已结束");
groupCourseDao.completeExpiredCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("定时任务完成,更新了 {} 条过期团课状态为已结束", count);
}
},
error -> logger.error("过期团课状态更新定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -0,0 +1,48 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 常态化团课自动续期定时任务
*
* 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'),
* 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。
*
* @date 2026-06-29
*/
@Component
public class GroupCourseRecurringScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已结束的常态化团课重置为正常状态,
* 并将上课时间/下课时间各推迟一周
*/
@Scheduled(fixedRate = 60000)
public void renewRecurringCourses() {
logger.debug("定时任务开始检查常态化团课,续期已结束的课程");
groupCourseDao.renewRecurringCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("常态化团课续期完成,更新了 {} 条课程", count);
}
},
error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -0,0 +1,26 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerService {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> create(Banner banner);
Mono<Banner> update(Long id, Banner banner);
Mono<Void> delete(Long id);
Mono<Banner> enable(Long id);
Mono<Banner> disable(Long id);
}
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.service; package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,6 +27,4 @@ public interface ICourseLabelService {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -17,9 +17,10 @@ public interface IGroupCourseBookingService {
* *
* @param courseId 团课ID * @param courseId 团课ID
* @param memberId 会员ID * @param memberId 会员ID
* @param memberCardRecordId 会员卡记录ID
* @return 预约记录 * @return 预约记录
*/ */
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId); Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId);
/** /**
* 取消预约 * 取消预约
@@ -61,4 +62,14 @@ public interface IGroupCourseBookingService {
* @return 处理的记录数 * @return 处理的记录数
*/ */
Mono<Integer> processAbsentMembers(); Mono<Integer> processAbsentMembers();
/**
* 扫码签到
* 用户扫描团课二维码签到,将预约状态更新为已出席(2)
*
* @param courseId 团课ID
* @param memberId 会员ID
* @return 更新后的预约记录
*/
Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId);
} }
@@ -6,20 +6,14 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
public interface IGroupCourseService { public interface IGroupCourseService {
Mono<GroupCourse> findById(Long id); Mono<GroupCourse> findById(Long id);
Mono<GroupCourseDetail> findDetailById(Long id); Mono<GroupCourseDetail> findDetailById(Long id);
Flux<GroupCourse> findAll(); Flux<GroupCourse> findAll();
Flux<GroupCourse> findAll(boolean includeDeleted); Flux<GroupCourse> findAll(boolean includeDeleted);
Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted);
Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted);
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted); Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
@@ -32,16 +26,8 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId); Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id); Mono<Void> delete(Long id);
Mono<GroupCourse> restore(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query); Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
/**
* 检查教练在指定时间段内是否有时间冲突的团课
* @param coachId 教练ID
* @param startTime 开始时间
* @param endTime 结束时间
* @param excludeCourseId 排除的课程ID(编辑时排除自身)
* @return 冲突的课程列表
*/
Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId);
} }
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.service; package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -31,6 +29,4 @@ public interface IGroupCourseTypeService {
* @return 分类名称列表 * @return 分类名称列表
*/ */
Flux<String> findCategories(); Flux<String> findCategories();
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -0,0 +1,99 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class BannerService implements IBannerService {
private static final Logger logger = LoggerFactory.getLogger(BannerService.class);
private final IBannerRepository bannerRepository;
public BannerService(IBannerRepository bannerRepository) {
this.bannerRepository = bannerRepository;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerRepository.findById(id);
}
@Override
public Flux<Banner> findAll() {
return bannerRepository.findAll();
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
return bannerRepository.findAll(sortBy, sortOrder);
}
@Override
public Flux<Banner> findAllActive() {
return bannerRepository.findAllActive();
}
@Override
public Mono<Banner> create(Banner banner) {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
return Mono.error(new RuntimeException("背景图不能为空"));
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
return Mono.error(new RuntimeException("主标题不能为空"));
}
return bannerRepository.save(banner)
.doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle()))
.doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<Banner> update(Long id, Banner banner) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(existing -> {
if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl());
if (banner.getTitle() != null) existing.setTitle(banner.getTitle());
if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle());
if (banner.getDescription() != null) existing.setDescription(banner.getDescription());
if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder());
if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive());
return bannerRepository.update(existing);
})
.doOnSuccess(r -> logger.info("轮播图更新成功 - id={}", id))
.doOnError(error -> logger.error("轮播图更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(banner -> bannerRepository.deleteById(id)
.doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id))
.doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage())));
}
@Override
public Mono<Banner> enable(Long id) {
return bannerRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图启用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Banner> disable(Long id) {
return bannerRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage()));
}
}
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.service.impl; 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.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
@@ -111,12 +109,4 @@ public class CourseLabelService implements ICourseLabelService {
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId)) .doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage())); .doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
} }
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
return courseLabelRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
}
} }
@@ -3,15 +3,19 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher; import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher;
import cn.novalon.gym.manage.groupcourse.handler.BookingSagaHandler;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.UUID; import java.util.UUID;
@@ -24,6 +28,7 @@ import java.util.UUID;
* - 取消预约需在课程开始前至少2小时 * - 取消预约需在课程开始前至少2小时
* - 每节课最多20人 * - 每节课最多20人
* - 预约成功后发送提醒 * - 预约成功后发送提醒
* - 预约成功后扣减权益
* *
* 技术要点: * 技术要点:
* - 使用Redis缓存团课信息 * - 使用Redis缓存团课信息
@@ -42,6 +47,8 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final IGroupCourseRepository courseRepository; private final IGroupCourseRepository courseRepository;
private final GroupCourseRedisService redisService; private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler;
private final DatabaseClient databaseClient;
// 预约提前时间限制(分钟) // 预约提前时间限制(分钟)
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -51,16 +58,20 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository, public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
IGroupCourseRepository courseRepository, IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService, GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher) { BookingReminderEventPublisher bookingReminderEventPublisher,
BookingSagaHandler bookingSagaHandler,
DatabaseClient databaseClient) {
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.courseRepository = courseRepository; this.courseRepository = courseRepository;
this.redisService = redisService; this.redisService = redisService;
this.bookingReminderEventPublisher = bookingReminderEventPublisher; this.bookingReminderEventPublisher = bookingReminderEventPublisher;
this.bookingSagaHandler = bookingSagaHandler;
this.databaseClient = databaseClient;
} }
@Override @Override
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId) { public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId) {
logger.info("开始预约团课:courseId={}, memberId={}", courseId, memberId); logger.info("开始预约团课:courseId={}, memberId={}, memberCardRecordId={}", courseId, memberId, memberCardRecordId);
// 生成唯一请求ID用于分布式锁 // 生成唯一请求ID用于分布式锁
String requestId = UUID.randomUUID().toString(); String requestId = UUID.randomUUID().toString();
@@ -72,26 +83,18 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
return Mono.error(new RuntimeException("系统繁忙,请稍后重试")); return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
} }
// 2. 从缓存或数据库获取课程信息,并用实时预约数覆盖currentMembers // 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取
return getCourseWithCache(courseId) return getCourseWithCache(courseId)
.flatMap(course ->
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
// 3. 验证课程状态 // 3. 验证课程状态
Long courseStatus = course.getStatus(); Long status = course.getStatus();
if (courseStatus == null || courseStatus != 0L) { if (status == null || status != 0L) {
String errorMessage; String errorMessage;
if (courseStatus == null) { if (status == null) {
errorMessage = "课程状态异常"; errorMessage = "课程状态异常";
} else if (courseStatus == 1L) { } else if (status == 1L) {
errorMessage = "课程已取消,无法预约"; errorMessage = "课程已取消,无法预约";
} else if (courseStatus == 2L) { } else if (status == 2L) {
errorMessage = "课程已结束,无法预约"; errorMessage = "课程已结束,无法预约";
} else { } else {
errorMessage = "课程状态不可预约"; errorMessage = "课程状态不可预约";
@@ -134,46 +137,57 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
// 7. 验证是否已预约 // 7. 验证是否已预约
return bookingRepository.findValidBooking(courseId, memberId) return bookingRepository.findValidBooking(courseId, memberId)
.flatMap(existingBooking -> .flatMap(existingBooking -> {
releaseLockAndError(courseId, requestId, "您已预约该课程") return releaseLockAndError(courseId, requestId, "您已预约该课程");
) })
.switchIfEmpty( .switchIfEmpty(
// 8. 创建预约记录 // 8. 使用Redis原子操作验证课程人数是否已满
Mono.defer(() -> { validateAndIncrementBookingCount(courseId, course.getMaxMembers())
GroupCourseBooking booking = new GroupCourseBooking(); .flatMap(countValid -> {
booking.setCourseId(courseId); if (countValid > course.getMaxMembers()) {
booking.setMemberId(memberId); return releaseLockAndError(courseId, requestId, "课程已满");
booking.setBookingTime(LocalDateTime.now()); }
booking.setStatus("0"); // 0-已预约
// 添加课程信息到预约记录 // 9. 创建预约记录
booking.setCourseName(course.getCourseName()); GroupCourseBooking booking = new GroupCourseBooking();
booking.setCourseStartTime(course.getStartTime()); booking.setCourseId(courseId);
booking.setCourseEndTime(course.getEndTime()); booking.setMemberId(memberId);
booking.setLocation(course.getLocation()); booking.setMemberCardRecordId(memberCardRecordId);
booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0"); // 0-已预约
// 9. 保存预约记录 // 添加课程信息到预约记录
return bookingRepository.save(booking) booking.setCourseName(course.getCourseName());
.flatMap(saved -> { booking.setCourseStartTime(course.getStartTime());
if (saved.getId() == null) { booking.setCourseEndTime(course.getEndTime());
return Mono.error(new RuntimeException("保存预约记录失败")); booking.setLocation(course.getLocation());
}
// 10. 释放锁 // 10. 使用Saga事务执行预约(包含权益扣减)
return redisService.releaseLock(courseId, requestId) BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.ZERO;
.then(Mono.just(saved)); return bookingSagaHandler.executeBooking(booking, memberCardRecordId, courseAmount)
}) .flatMap(savedBooking -> {
.doOnSuccess(savedBooking -> { // 11. 释放锁
logger.info("预约成功:bookingId={}, courseId={}, memberId={}", return redisService.releaseLock(courseId, requestId)
savedBooking.getId(), courseId, memberId); .then(Mono.just(savedBooking));
// 发布预约成功事件 })
bookingReminderEventPublisher.publishBookingSuccessEvent( .doOnSuccess(savedBooking -> {
savedBooking.getId(), logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
savedBooking.getMemberId(), savedBooking.getId(), courseId, memberId);
savedBooking.getCourseName(), // 发布预约成功事件
savedBooking.getCourseStartTime().toString() 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());
});
})
); );
}); });
}) })
@@ -201,6 +215,29 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
); );
} }
/**
* 验证并增加预约人数(使用Redis原子操作)
*/
private Mono<Integer> validateAndIncrementBookingCount(Long courseId, Integer maxMembers) {
// 先获取当前Redis中的预约计数
return redisService.getBookingCount(courseId)
.flatMap(currentCount -> {
// 如果Redis中计数为0,可能是首次访问,需要从数据库同步
if (currentCount == 0) {
// 从数据库查询实际预约人数
return bookingRepository.countValidBookings(courseId)
.flatMap(dbCount -> {
// 将数据库中的实际预约人数同步到Redis
return redisService.setBookingCount(courseId, dbCount.intValue())
.then(Mono.just(dbCount.intValue()));
});
}
return Mono.just(currentCount);
})
// 递增预约计数
.flatMap(count -> redisService.incrementBookingCount(courseId).map(Long::intValue));
}
/** /**
* 释放锁并返回错误 * 释放锁并返回错误
*/ */
@@ -260,29 +297,33 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消"); "需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
} }
// 5. 更新预约状态为已取消 // 5. 查询课程金额和已取消次数,使用Saga事务执行取消预约
return bookingRepository.updateStatus(bookingId, "1") return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
.flatMap(rows -> { .flatMap(course -> {
if (rows == 0) { BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.valueOf(50);
return Mono.error(new RuntimeException("更新预约状态失败")); return bookingRepository.countCancelledByMemberId(memberId)
} .flatMap(cancelCount -> {
// 6. 释放锁 return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId, courseAmount, cancelCount);
return redisService.releaseLock(bookingId, requestId) });
.then(bookingRepository.findById(bookingId)); })
}) .flatMap(updatedBooking -> {
.doOnSuccess(updatedBooking -> { // 6. 释放锁
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); return redisService.releaseLock(bookingId, requestId)
// 发布预约取消事件 .then(Mono.just(updatedBooking));
bookingReminderEventPublisher.publishBookingCancelEvent( })
updatedBooking.getId(), .doOnSuccess(updatedBooking -> {
updatedBooking.getMemberId(), logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
updatedBooking.getCourseName() // 发布预约取消事件
); bookingReminderEventPublisher.publishBookingCancelEvent(
}) updatedBooking.getId(),
.doOnError(error -> { updatedBooking.getMemberId(),
logger.error("取消预约失败:bookingId={}, memberId={}, error={}", updatedBooking.getCourseName()
bookingId, memberId, error.getMessage()); );
}); })
.doOnError(error -> {
logger.error("取消预约失败:bookingId={}, memberId={}, error={}",
bookingId, memberId, error.getMessage());
});
}) })
.onErrorResume(error -> { .onErrorResume(error -> {
redisService.releaseLock(bookingId, requestId).subscribe(); redisService.releaseLock(bookingId, requestId).subscribe();
@@ -295,6 +336,18 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) { public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
logger.debug("查询会员预约记录:memberId={}", memberId); logger.debug("查询会员预约记录:memberId={}", memberId);
return bookingRepository.findByMemberId(memberId) return bookingRepository.findByMemberId(memberId)
.flatMap(booking -> {
// 从关联的 GroupCourse 获取封面图并转为预签名URL
if (booking.getCourseId() != null) {
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
.map(course -> {
booking.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
return booking;
})
.defaultIfEmpty(booking);
}
return Mono.just(booking);
})
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId)); .doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
} }
@@ -311,6 +364,78 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
} }
@Override
public Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId) {
logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId);
return courseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> {
// 校验1:团课状态必须为 0(正常)
Long status = course.getStatus();
if (status == null || status != 0L) {
String msg;
if (status == null) msg = "课程状态异常";
else if (status == 1L) msg = "课程已取消,无法签到";
else if (status == 2L) msg = "课程已结束,无法签到";
else msg = "课程状态不可签到";
return Mono.error(new RuntimeException(msg));
}
// 校验2:用户是否预约了该课程(状态为 0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
// 校验3:预约状态必须为 0
if (!"0".equals(booking.getStatus())) {
String msg;
if ("1".equals(booking.getStatus())) msg = "预约已取消";
else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到";
else msg = "预约状态异常";
return Mono.error(new RuntimeException(msg));
}
// 更新预约状态为 2(已出席)
return bookingRepository.updateStatus(booking.getId(), "2")
.then(Mono.defer(() -> {
// 同步写入签到记录,供仪表盘统计
LocalDateTime now = LocalDateTime.now();
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 先检查今天是否已有成功签到记录,避免重复
return databaseClient.sql(
"SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1")
.bind("memberId", memberId)
.bind("startTime", todayStart)
.bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.flatMap(existing -> {
// 已有签到记录,不重复插入
return bookingRepository.findById(booking.getId());
})
.switchIfEmpty(
// 无今日签到记录,插入一条
databaseClient.sql(
"INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " +
"VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)")
.bind("memberId", memberId)
.bind("memberCardId", booking.getMemberCardRecordId())
.bind("signInTime", now)
.fetch()
.rowsUpdated()
.then(bookingRepository.findById(booking.getId()))
);
}));
});
})
.doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}",
booking.getId(), courseId, memberId))
.doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage()));
}
@Override @Override
public Mono<Integer> processAbsentMembers() { public Mono<Integer> processAbsentMembers() {
logger.info("开始处理已开始课程但未到场会员的预约记录"); logger.info("开始处理已开始课程但未到场会员的预约记录");
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -134,6 +135,8 @@ public class GroupCourseRecommendService implements IGroupCourseRecommendService
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId()) return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> { .map(course -> {
// 将 OSS Key 转换为预签名URL,前端可直接加载
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
recommend.setGroupCourse(course); recommend.setGroupCourse(course);
return recommend; return recommend;
}) })
@@ -149,4 +149,46 @@ public class GroupCourseRedisService {
}) })
.defaultIfEmpty(false); .defaultIfEmpty(false);
} }
/**
* 获取课程预约人数(缓存)
*/
public Mono<Integer> getBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.get(key)
.map(obj -> (Integer) obj)
.defaultIfEmpty(0);
}
/**
* 增加课程预约人数
*/
public Mono<Long> incrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.increment(key)
.doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count));
}
/**
* 减少课程预约人数
*/
public Mono<Long> decrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.decrement(key)
.doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count));
}
/**
* 设置课程预约人数(用于从数据库同步)
*/
public Mono<Void> setBookingCount(Long courseId, Integer count) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.set(key, count)
.doOnSuccess(result -> logger.debug("预约人数已设置:courseId={}, count={}", courseId, count))
.then();
}
} }
@@ -19,15 +19,12 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil; import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO; import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardRecordService; import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -36,11 +33,10 @@ import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@@ -57,8 +53,6 @@ public class GroupCourseService implements IGroupCourseService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final GroupCourseStateMachine stateMachine; private final GroupCourseStateMachine stateMachine;
private final DatabaseClient databaseClient; private final DatabaseClient databaseClient;
private final ISysFileService fileService;
private final ISysUserRepository sysUserRepository;
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
@@ -76,9 +70,7 @@ public class GroupCourseService implements IGroupCourseService {
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper,
GroupCourseStateMachine stateMachine, GroupCourseStateMachine stateMachine,
DatabaseClient databaseClient, DatabaseClient databaseClient){
ISysFileService fileService,
ISysUserRepository sysUserRepository){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository; this.groupCourseTypeRepository = groupCourseTypeRepository;
@@ -89,22 +81,19 @@ public class GroupCourseService implements IGroupCourseService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.stateMachine = stateMachine; this.stateMachine = stateMachine;
this.databaseClient = databaseClient; this.databaseClient = databaseClient;
this.fileService = fileService;
this.sysUserRepository = sysUserRepository;
} }
@Override @Override
public Mono<GroupCourseDetail> findDetailById(Long id) { public Mono<GroupCourseDetail> findDetailById(Long id) {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + id; String cacheKey = CACHE_KEY_DETAIL_PREFIX + id;
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class); return redisUtil.get(cacheKey, String.class)
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null && !cachedJson.isEmpty()) { if (cachedJson != null) {
try { try {
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class); GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
logger.info("缓存命中 - findDetailById: id={}", id); logger.info("缓存命中 - findDetailById: id={}", id);
return Mono.<GroupCourseDetail>just(detail); return Mono.just(detail);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -136,8 +125,6 @@ public class GroupCourseService implements IGroupCourseService {
}) })
.switchIfEmpty(Mono.just(buildDetail(course, null))); .switchIfEmpty(Mono.just(buildDetail(course, null)));
}) })
.flatMap(this::enrichCoachName)
.flatMap(this::enrichCurrentMembers)
.flatMap(detail -> { .flatMap(detail -> {
try { try {
String jsonData = objectMapper.writeValueAsString(detail); String jsonData = objectMapper.writeValueAsString(detail);
@@ -168,7 +155,7 @@ public class GroupCourseService implements IGroupCourseService {
detail.setCurrentMembers(course.getCurrentMembers()); detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus()); detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation()); detail.setLocation(course.getLocation());
detail.setCoverImage(course.getCoverImage()); detail.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
detail.setDescription(course.getDescription()); detail.setDescription(course.getDescription());
detail.setStoredValueAmount(course.getStoredValueAmount()); detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath()); detail.setQrCodePath(course.getQrCodePath());
@@ -183,64 +170,17 @@ public class GroupCourseService implements IGroupCourseService {
return detail; return detail;
} }
/**
* 为团课详情对象填充教练名称
*/
private Mono<GroupCourseDetail> enrichCoachName(GroupCourseDetail detail) {
Long coachId = detail.getCoachId();
if (coachId == null) {
return Mono.just(detail);
}
return sysUserRepository.findByIdIncludingDeleted(coachId)
.map(user -> {
String name = user.getNickname() != null ? user.getNickname() : user.getUsername();
detail.setCoachName(name);
logger.debug("enrichCoachName: courseId={}, coachId={}, coachName={}", detail.getId(), coachId, name);
return detail;
})
.doOnNext(d -> {}) // 避免空doOnNext告警
.defaultIfEmpty(detail)
.doOnDiscard(GroupCourseDetail.class, d ->
logger.warn("enrichCoachName: coach not found, coachId={}, courseId={}", coachId, detail.getId())
);
}
/**
* 为团课详情对象填充实时预约人数
*/
private Mono<GroupCourseDetail> enrichCurrentMembers(GroupCourseDetail detail) {
return bookingRepository.countValidBookings(detail.getId())
.map(count -> {
detail.setCurrentMembers(count.intValue());
return detail;
})
.defaultIfEmpty(detail);
}
/**
* 为团课对象填充实时预约人数
*/
private Mono<GroupCourse> enrichCurrentMembers(GroupCourse course) {
return bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course);
}
@Override @Override
public Mono<GroupCourse> findById(Long id) { public Mono<GroupCourse> findById(Long id) {
String cacheKey = CACHE_KEY_ID_PREFIX + id; String cacheKey = CACHE_KEY_ID_PREFIX + id;
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class); return redisUtil.get(cacheKey, String.class)
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null && !cachedJson.isEmpty()) { if (cachedJson != null) {
try { try {
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class); GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
logger.info("缓存命中 - findById: id={}", id); logger.info("缓存命中 - findById: id={}", id);
return Mono.<GroupCourse>just(groupCourse); return Mono.just(fillCoverPresignedUrl(groupCourse));
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -250,6 +190,7 @@ public class GroupCourseService implements IGroupCourseService {
}) })
.switchIfEmpty( .switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id) groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.map(this::fillCoverPresignedUrl)
.flatMap(groupCourse -> { .flatMap(groupCourse -> {
try { try {
String jsonData = objectMapper.writeValueAsString(groupCourse); String jsonData = objectMapper.writeValueAsString(groupCourse);
@@ -262,63 +203,24 @@ public class GroupCourseService implements IGroupCourseService {
} }
}) })
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id)) .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
) );
.flatMap(this::enrichCurrentMembers);
} }
@Override @Override
public Flux<GroupCourse> findAll() { public Flux<GroupCourse> findAll() {
return groupCourseRepository.findAll(); return groupCourseRepository.findAll()
.map(this::fillCoverPresignedUrl);
} }
@Override @Override
public Flux<GroupCourse> findAll(boolean includeDeleted) { public Flux<GroupCourse> findAll(boolean includeDeleted) {
Flux<GroupCourse> flux;
if(includeDeleted){ if(includeDeleted){
return groupCourseRepository.findAll(); flux = groupCourseRepository.findAll();
}else{ }else{
return groupCourseRepository.findByDeletedAtIsNull(); flux = groupCourseRepository.findByDeletedAtIsNull();
} }
} return flux.map(this::fillCoverPresignedUrl);
@Override
public Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted) {
return findAll(includeDeleted)
.flatMap(this::enrichCurrentMembers)
.collectList()
.flatMapMany(courses -> {
if (courses.isEmpty()) {
logger.info("findAllAsVO: no courses found");
return Flux.empty();
}
var coachIds = courses.stream()
.map(GroupCourse::getCoachId)
.filter(id -> id != null)
.distinct()
.toList();
logger.info("findAllAsVO: {} courses, {} unique coach IDs: {}", courses.size(), coachIds.size(), coachIds);
if (coachIds.isEmpty()) {
logger.info("findAllAsVO: no coach IDs, returning VOs with null coachName");
return Flux.fromIterable(courses.stream()
.map(c -> GroupCourseVO.from(c, null))
.toList());
}
return Flux.fromIterable(coachIds)
.flatMap(sysUserRepository::findByIdIncludingDeleted)
.doOnNext(u -> logger.info("findAllAsVO: found coach id={}, nickname={}, username={}", u.getId(), u.getNickname(), u.getUsername()))
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
.doOnNext(m -> logger.info("findAllAsVO: coachNameMap size={}, keys={}", m.size(), m.keySet()))
.flatMapMany(coachNameMap ->
Flux.fromIterable(courses.stream()
.map(c -> {
String name = coachNameMap.get(c.getCoachId());
logger.debug("findAllAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
return GroupCourseVO.from(c, name);
})
.toList()));
});
} }
@Override @Override
@@ -328,19 +230,18 @@ public class GroupCourseService implements IGroupCourseService {
String sort = pageRequest.getSort(); String sort = pageRequest.getSort();
String order = pageRequest.getOrder(); String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : ""; String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class); return redisUtil.get(cacheKey, String.class)
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null && !cachedJson.isEmpty()) { if (cachedJson != null) {
try { try {
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson, PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class)); objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
logger.info("缓存命中 - findByPage: key={}", cacheKey); logger.info("缓存命中 - findByPage: key={}", cacheKey);
return Mono.<PageResponse<GroupCourse>>just(pageResponse); fillCoverPresignedUrl(pageResponse);
return Mono.just(pageResponse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -360,6 +261,7 @@ public class GroupCourseService implements IGroupCourseService {
return resultMono.flatMap(pageResponse -> { return resultMono.flatMap(pageResponse -> {
try { try {
fillCoverPresignedUrl(pageResponse);
String jsonData = objectMapper.writeValueAsString(pageResponse); String jsonData = objectMapper.writeValueAsString(pageResponse);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS) return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(pageResponse) .thenReturn(pageResponse)
@@ -373,73 +275,6 @@ public class GroupCourseService implements IGroupCourseService {
); );
} }
@Override
public Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted) {
return findByPage(pageRequest, includeDeleted)
.flatMap(pageResponse -> {
List<GroupCourse> courses = pageResponse.getContent();
if (courses.isEmpty()) {
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
Collections.emptyList(),
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
return Mono.just(voResponse);
}
// 用实时预约人数覆盖缓存/DB中的currentMembers
return Flux.fromIterable(courses)
.flatMap(this::enrichCurrentMembers)
.collectList()
.flatMap(enrichedCourses -> {
var coachIds = enrichedCourses.stream()
.map(GroupCourse::getCoachId)
.filter(id -> id != null)
.distinct()
.toList();
logger.info("findByPageAsVO: {} courses, {} unique coach IDs: {}", enrichedCourses.size(), coachIds.size(), coachIds);
if (coachIds.isEmpty()) {
List<GroupCourseVO> voList = enrichedCourses.stream()
.map(c -> GroupCourseVO.from(c, null))
.toList();
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
voList,
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
return Mono.just(voResponse);
}
return Flux.fromIterable(coachIds)
.flatMap(sysUserRepository::findByIdIncludingDeleted)
.doOnNext(u -> logger.info("findByPageAsVO: found coach id={}, nickname={}", u.getId(), u.getNickname()))
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
.map(coachNameMap -> {
List<GroupCourseVO> voList = enrichedCourses.stream()
.map(c -> {
String name = coachNameMap.get(c.getCoachId());
logger.debug("findByPageAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
return GroupCourseVO.from(c, name);
})
.toList();
return new PageResponse<>(
voList,
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
});
});
});
}
@Override @Override
public Mono<GroupCourse> create(GroupCourse groupCourse) { public Mono<GroupCourse> create(GroupCourse groupCourse) {
return groupCourseRepository.save(groupCourse) return groupCourseRepository.save(groupCourse)
@@ -460,21 +295,15 @@ public class GroupCourseService implements IGroupCourseService {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码字节数组,通过统一文件服务保存 // 生成二维码并上传到阿里云OSS
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent)) String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
.flatMap(qrCodeBytes -> { course.setQrCodePath(qrCodeUrl);
String fileName = "qrcode_" + course.getId() + ".png";
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system"); logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl);
})
.flatMap(sysFile -> { // 更新团课信息,保存二维码路径
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview"; return groupCourseRepository.update(course)
course.setQrCodePath(qrCodeUrl); .doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
logger.info("团课二维码已保存 - id={}, fileId={}, url={}", course.getId(), sysFile.getId(), qrCodeUrl);
return groupCourseRepository.update(course)
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
});
} catch (Exception e) { } catch (Exception e) {
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e); logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
// 即使二维码生成失败,也返回成功创建的团课 // 即使二维码生成失败,也返回成功创建的团课
@@ -526,6 +355,9 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getQrCodePath() != null) { if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath()); existing.setQrCodePath(groupCourse.getQrCodePath());
} }
if (groupCourse.getIsRecurring() != null) {
existing.setIsRecurring(groupCourse.getIsRecurring());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -656,15 +488,6 @@ public class GroupCourseService implements IGroupCourseService {
public Mono<GroupCourse> signIn(Long courseId, Long memberId) { public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course ->
// 用实时预约人数覆盖currentMembers
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
// 校验1:团课已取消 // 校验1:团课已取消
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
@@ -691,13 +514,30 @@ public class GroupCourseService implements IGroupCourseService {
return Mono.error(new RuntimeException("课程已满员,无法签到")); return Mono.error(new RuntimeException("课程已满员,无法签到"));
} }
// 校验5:用户已预约此课程(有效预约,状态为0-已预约 // 校验5:用户今日是否已到店签到(直接查询sign_in_record表
return bookingRepository.findValidBooking(courseId, memberId) LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此团课"))) LocalDateTime todayEnd = todayStart.plusDays(1);
.flatMap(booking -> { return databaseClient.sql("SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false ORDER BY sign_in_time DESC LIMIT 1")
// 更新预约状态为已出席(2),不再手动计数 .bind("memberId", memberId)
return bookingRepository.updateStatus(booking.getId(), "2") .bind("startTime", todayStart)
.thenReturn(course); .bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.switchIfEmpty(Mono.error(new RuntimeException("请先完成到店签到")))
.flatMap(status -> {
if (!"SUCCESS".equals(status)) {
return Mono.error(new RuntimeException("到店签到未成功,请重新签到"));
}
// 校验6:用户已预约此课程(有效预约,状态为0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
return groupCourseRepository.updateCurrentMembers(courseId, 1)
.flatMap(updatedCourse -> {
return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse);
});
});
}); });
}) })
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId)) .doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
@@ -707,11 +547,10 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 只有已取消或已结束的课程才能删除 // 已取消或已结束的课程才能删除
return groupCourseRepository.findByIdAndDeletedAtIsNull(id) return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> { .flatMap(course -> {
// 检查课程状态是否为已取消(状态码1)或已结束(状态码2)
Long status = course.getStatus(); Long status = course.getStatus();
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) { if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " + return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
@@ -726,6 +565,14 @@ public class GroupCourseService implements IGroupCourseService {
}); });
} }
@Override
public Mono<GroupCourse> restore(Long id) {
return groupCourseRepository.restoreById(id)
.doOnSuccess(course -> logger.info("团课恢复成功 - id={}", id))
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课恢复失败 - id={}, error: {}", id, error.getMessage()));
}
@Override @Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) { public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}", logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
@@ -733,45 +580,36 @@ public class GroupCourseService implements IGroupCourseService {
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost()); query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query) return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}", .doOnSuccess(result -> {
result.getTotalElements(), result.getCurrentPage(), result.getPageSize())) fillCoverPresignedUrl(result);
logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize());
})
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage())); .doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
} }
private Mono<Void> clearCache() { private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*") return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")) .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")) .then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
.then(redisUtil.deleteByPattern("datacount:statistics:*"))
.then();
} }
@Override /**
public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) { * 将单个 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
if (coachId == null || startTime == null || endTime == null) { */
return Mono.just(Collections.emptyList()); private GroupCourse fillCoverPresignedUrl(GroupCourse course) {
if (course != null) {
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
}
return course;
}
/**
* 将分页结果中所有 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
*/
private void fillCoverPresignedUrl(PageResponse<GroupCourse> pageResponse) {
if (pageResponse != null && pageResponse.getContent() != null) {
pageResponse.getContent().forEach(this::fillCoverPresignedUrl);
} }
return groupCourseRepository.findByCoachId(coachId)
.filter(course -> {
// 排除已取消的课程
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return false;
}
// 排除自身(编辑时)
if (excludeCourseId != null && course.getId() != null && course.getId().equals(excludeCourseId)) {
return false;
}
// 检查时间是否重叠:startTime < course.endTime AND course.startTime < endTime
if (course.getStartTime() == null || course.getEndTime() == null) {
return false;
}
return startTime.isBefore(course.getEndTime()) && course.getStartTime().isBefore(endTime);
})
.collectList()
.doOnNext(conflicts -> {
if (!conflicts.isEmpty()) {
logger.info("checkCoachConflict: coachId={}, conflicts={} courses", coachId, conflicts.size());
}
});
} }
} }
@@ -1,7 +1,5 @@
package cn.novalon.gym.manage.groupcourse.service.impl; 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.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
@@ -21,7 +19,7 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository, public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) { IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository; this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
} }
@@ -74,17 +72,16 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 检查是否有团课依赖该类型
return groupCourseRepository.findByCourseType(id) return groupCourseRepository.findByCourseType(id)
.hasElements() .hasElements()
.flatMap(hasDependents -> { .flatMap(hasCourses -> {
if (hasDependents) { if (hasCourses) {
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除")); return Mono.<Void>error(new RuntimeException("该类型下存在关联团课,无法删除"));
} }
return groupCourseTypeRepository.deleteById(id) return groupCourseTypeRepository.deleteById(id);
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) })
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); .doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
}); .doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
} }
@Override @Override
@@ -94,12 +91,4 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
.filter(category -> category != null && !category.isEmpty()) .filter(category -> category != null && !category.isEmpty())
.distinct(); .distinct();
} }
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
return groupCourseTypeRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
}
} }
@@ -0,0 +1,193 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 阿里云OSS工具类
*/
public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息
private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y";
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq";
private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
// 文件存储目录
private static final String QRCODE_DIR = "qrcode/";
private static final String COVER_DIR = "cover/";
// 预签名URL有效期(秒)
private static final long PRESIGN_EXPIRE_SECONDS = 300;
// 封面图预签名URL有效期(秒)- 1小时,确保前端列表页足够展示
private static final long COVER_PRESIGN_EXPIRE_SECONDS = 3600;
/**
* 上传文件到阿里云OSS(文件默认继承Bucket权限,不设置ACL)
*
* @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传文件到阿里云OSS(自定义存储路径,文件默认继承Bucket权限)
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String ossFilePath = ossDirectory + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传封面图到阿里云OSS(使用InputStream,文件默认继承Bucket权限)
*
* @param inputStream 文件输入流
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadCoverToOSS(InputStream inputStream, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = COVER_DIR + datePath + "/" + fileName;
ossClient.putObject(BUCKET_NAME, ossFilePath, inputStream);
logger.info("封面上传到OSS成功: fileName={}, ossKey={}", fileName, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("封面上传到OSS失败 - fileName: {}, error: {}", fileName, e.getMessage(), e);
throw new RuntimeException("封面上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 生成OSS对象预签名URL(临时访问链接)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey) {
return generatePresignedUrl(ossKey, PRESIGN_EXPIRE_SECONDS);
}
/**
* 生成OSS对象预签名URL(可指定有效期)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @param expireSeconds 有效期(秒)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey, long expireSeconds) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(BUCKET_NAME, ossKey, HttpMethod.GET);
request.setExpiration(expiration);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
} catch (Exception e) {
logger.error("生成预签名URL失败 - ossKey: {}, error: {}", ossKey, e.getMessage(), e);
throw new RuntimeException("生成预签名URL失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 根据OSS Key拼接公开访问URL(仅当Bucket为公共读时有效)
*/
public static String getPublicUrl(String ossKey) {
return OSS_URL_PREFIX + ossKey;
}
/**
* 将OSS Key(相对路径)转换为封面图预签名URL(1小时有效期)
* 如果已经是HTTP(S)完整URL则直接返回
*
* @param ossKey OSS对象Key或完整URL
* @return 可访问的预签名URLossKey为空时返回null
*/
public static String toCoverPresignedUrl(String ossKey) {
if (ossKey == null || ossKey.isBlank()) {
return null;
}
if (ossKey.startsWith("http://") || ossKey.startsWith("https://")) {
return ossKey;
}
return generatePresignedUrl(ossKey, COVER_PRESIGN_EXPIRE_SECONDS);
}
}
@@ -10,33 +10,46 @@ import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 二维码生成工具类 * 二维码生成工具类
* 生成二维码的字节数组,由调用方统一通过文件服务保存
*/ */
public class QRCodeUtil { public class QRCodeUtil {
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class); private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
// 二维码默认保存路径(本地临时路径)
private static final String DEFAULT_SAVE_PATH = "D:\\Games\\exmp\\image";
// 二维码尺寸 // 二维码尺寸
private static final int QR_CODE_WIDTH = 300; private static final int QR_CODE_WIDTH = 300;
private static final int QR_CODE_HEIGHT = 300; private static final int QR_CODE_HEIGHT = 300;
/** /**
* 生成二维码图片的字节数组 * 生成二维码并保存到指定路径
* *
* @param content 二维码内容 * @param content 二维码内容
* @return PNG格式的二维码图片字节数组 * @param savePath 保存路径
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/ */
public static byte[] generateQrCodeBytes(String content) { public static String generateQRCode(String content, String savePath, String fileName) {
try { try {
Path directory = Paths.get(savePath);
if (!Files.exists(directory)) {
Files.createDirectories(directory);
logger.info("创建二维码保存目录: {}", savePath);
}
Map<EncodeHintType, Object> hints = new HashMap<>(); Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
@@ -45,21 +58,99 @@ public class QRCodeUtil {
QRCodeWriter qrCodeWriter = new QRCodeWriter(); QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints); BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix); String filePath = Paths.get(savePath, fileName + ".png").toString();
Path path = Paths.get(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream(); MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path);
ImageIO.write(image, "PNG", baos); logger.info("二维码生成成功: {}", filePath);
byte[] bytes = baos.toByteArray();
baos.close();
logger.info("二维码字节数组生成成功, size={} bytes", bytes.length); return filePath;
return bytes;
} catch (WriterException e) { } catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e); logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e); throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) { } catch (IOException e) {
logger.error("二维码输出失败 - IOException: {}", e.getMessage(), e); logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码输出失败: " + e.getMessage(), e); throw new RuntimeException("二维码保存失败: " + e.getMessage(), e);
}
}
/**
* 生成二维码并保存到默认路径
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 生成的二维码文件完整路径
*/
public static String generateQRCode(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp;
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并保存到默认路径,使用自定义文件名
*
* @param content 二维码内容
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/
public static String generateQRCodeWithFileName(String content, String fileName) {
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp + ".png";
return generateQRCodeAndUploadToOSS(content, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
*
* @param content 二维码内容
* @param fileName 文件名(含扩展名)
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content, String fileName) {
try {
Path tempDir = Files.createTempDirectory("qrcode_temp");
String tempFilePath = tempDir.resolve(fileName).toString();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", Paths.get(tempFilePath));
logger.info("二维码临时文件生成成功: {}", tempFilePath);
String ossUrl = OSSUtil.uploadToOSS(tempFilePath, fileName);
Files.deleteIfExists(Paths.get(tempFilePath));
Files.deleteIfExists(tempDir);
logger.info("临时文件已删除: {}", tempFilePath);
return ossUrl;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码处理失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码处理失败: " + e.getMessage(), e);
} }
} }
} }
@@ -1,134 +0,0 @@
package cn.novalon.gym.manage.groupcourse.vo;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 团课视图对象(含教练昵称)
*
* @author 张翔
* @date 2026-07-19
*/
public class GroupCourseVO {
private Long id;
private String courseName;
private Long coachId;
private String coachName;
private Long courseType;
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime actualStartTime;
private LocalDateTime actualEndTime;
private Integer maxMembers;
private Integer currentMembers;
private Long status;
private String location;
private String coverImage;
private String description;
private BigDecimal storedValueAmount;
private String qrCodePath;
private String createBy;
private String updateBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public GroupCourseVO() {}
/**
* 从领域对象和教练名称构建 VO
*/
public static GroupCourseVO from(GroupCourse course, String coachName) {
GroupCourseVO vo = new GroupCourseVO();
vo.setId(course.getId());
vo.setCourseName(course.getCourseName());
vo.setCoachId(course.getCoachId());
vo.setCoachName(coachName);
vo.setCourseType(course.getCourseType());
vo.setStartTime(course.getStartTime());
vo.setEndTime(course.getEndTime());
vo.setActualStartTime(course.getActualStartTime());
vo.setActualEndTime(course.getActualEndTime());
vo.setMaxMembers(course.getMaxMembers());
vo.setCurrentMembers(course.getCurrentMembers());
vo.setStatus(course.getStatus());
vo.setLocation(course.getLocation());
vo.setCoverImage(course.getCoverImage());
vo.setDescription(course.getDescription());
vo.setStoredValueAmount(course.getStoredValueAmount());
vo.setQrCodePath(course.getQrCodePath());
vo.setCreateBy(course.getCreateBy());
vo.setUpdateBy(course.getUpdateBy());
vo.setCreatedAt(course.getCreatedAt());
vo.setUpdatedAt(course.getUpdatedAt());
return vo;
}
// -- getters / setters --
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getCourseName() { return courseName; }
public void setCourseName(String courseName) { this.courseName = courseName; }
public Long getCoachId() { return coachId; }
public void setCoachId(Long coachId) { this.coachId = coachId; }
public String getCoachName() { return coachName; }
public void setCoachName(String coachName) { this.coachName = coachName; }
public Long getCourseType() { return courseType; }
public void setCourseType(Long courseType) { this.courseType = courseType; }
public LocalDateTime getStartTime() { return startTime; }
public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; }
public LocalDateTime getEndTime() { return endTime; }
public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; }
public LocalDateTime getActualStartTime() { return actualStartTime; }
public void setActualStartTime(LocalDateTime actualStartTime) { this.actualStartTime = actualStartTime; }
public LocalDateTime getActualEndTime() { return actualEndTime; }
public void setActualEndTime(LocalDateTime actualEndTime) { this.actualEndTime = actualEndTime; }
public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public Integer getCurrentMembers() { return currentMembers; }
public void setCurrentMembers(Integer currentMembers) { this.currentMembers = currentMembers; }
public Long getStatus() { return status; }
public void setStatus(Long status) { this.status = status; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getCoverImage() { return coverImage; }
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getStoredValueAmount() { return storedValueAmount; }
public void setStoredValueAmount(BigDecimal storedValueAmount) { this.storedValueAmount = storedValueAmount; }
public String getQrCodePath() { return qrCodePath; }
public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public String getUpdateBy() { return updateBy; }
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -1,6 +1,9 @@
package cn.novalon.gym.manage.groupcourse.util; package cn.novalon.gym.manage.groupcourse.util;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -9,41 +12,58 @@ import static org.junit.jupiter.api.Assertions.*;
*/ */
class QRCodeUtilTest { class QRCodeUtilTest {
@TempDir
Path tempDir;
@Test @Test
void testGenerateQrCodeBytes() { void testGenerateQRCode() {
String content = "测试二维码内容"; String content = "测试二维码内容";
byte[] bytes = QRCodeUtil.generateQrCodeBytes(content); String qrCodePath = QRCodeUtil.generateQRCode(content);
assertNotNull(bytes, "二维码字节数组不应为空"); assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(bytes.length > 0, "二维码字节数组应包含数据"); assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
System.out.println("生成的二维码字节大小: " + bytes.length + " bytes"); System.out.println("生成的二维码路径: " + qrCodePath);
} }
@Test @Test
void testGenerateQrCodeBytesWithJsonContent() { void testGenerateQRCodeWithCustomPath() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-07-14T10:00:00\"}"; String content = "自定义路径测试";
String customPath = tempDir.toString();
String fileName = "test_qrcode";
byte[] bytes = QRCodeUtil.generateQrCodeBytes(jsonContent); String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName);
assertNotNull(bytes, "二维码字节数组不应为空"); assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(bytes.length > 0, "二维码字节数组应包含数据"); assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
System.out.println("JSON内容二维码字节大小: " + bytes.length + " bytes"); System.out.println("生成的二维码路径: " + qrCodePath);
} }
@Test @Test
void testGenerateQrCodeBytesWithLongContent() { void testGenerateQRCodeWithJsonContent() {
StringBuilder sb = new StringBuilder(); String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
for (int i = 0; i < 100; i++) {
sb.append("这是第").append(i).append("行测试数据\n");
}
byte[] bytes = QRCodeUtil.generateQrCodeBytes(sb.toString()); String qrCodePath = QRCodeUtil.generateQRCode(jsonContent);
assertNotNull(bytes, "二维码字节数组不应为空"); assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(bytes.length > 0, "长内容二维码应正常生成"); assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
System.out.println("内容二维码字节大小: " + bytes.length + " bytes"); System.out.println("JSON内容二维码路径: " + qrCodePath);
} }
}
@Test
void testGenerateQRCodeAndUploadToOSS() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
assertNotNull(ossUrl, "OSS访问地址不应为空");
assertTrue(ossUrl.startsWith("qrcode/"), "OSS访问地址应以qrcode/开头");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl);
}
}
@@ -16,9 +16,6 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "wechat") @ConfigurationProperties(prefix = "wechat")
public class WechatProperties { public class WechatProperties {
// Mock模式:true=使用模拟数据,false=调用真实微信API
private Boolean mockEnabled;
// 小程序配置 // 小程序配置
private MiniApp miniapp = new MiniApp(); private MiniApp miniapp = new MiniApp();
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
/**
* 管理员编辑会员信息DTO(含密码验证)
*
* @author system
* @date 2026-06-24
*/
@Data
public class AdminEditMemberDto {
/**
* 管理员密码(必填,用于验证身份)
*/
private String adminPassword;
/**
* 昵称
*/
private String nickname;
/**
* 性别
*/
private GenderEnum gender;
/**
* 生日
*/
private LocalDate birthday;
/**
* 地址
*/
private String address;
/**
* 头像
*/
private String avatar;
/**
* 转换为 UpdateMemberInfoDto
*/
public UpdateMemberInfoDto toUpdateMemberInfoDto() {
UpdateMemberInfoDto dto = new UpdateMemberInfoDto();
dto.setNickname(nickname);
dto.setGender(gender);
dto.setBirthday(birthday);
dto.setAddress(address);
dto.setAvatar(avatar);
return dto;
}
}
@@ -2,16 +2,21 @@ package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员卡管理处理器 * 会员卡管理处理器
* *
@@ -24,9 +29,13 @@ import reactor.core.publisher.Mono;
public class MemberCardHandler { public class MemberCardHandler {
private final IMemberCardService memberCardService; private final IMemberCardService memberCardService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public MemberCardHandler(IMemberCardService memberCardService) { public MemberCardHandler(IMemberCardService memberCardService, ISysUserService sysUserService, AuthUtil authUtil) {
this.memberCardService = memberCardService; this.memberCardService = memberCardService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情") @Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
@@ -60,38 +69,72 @@ public class MemberCardHandler {
.flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card)); .flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card));
} }
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息,需验证管理员密码")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id) String adminPassword = request.queryParam("adminPassword").orElse(null);
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
.zipWith(request.bodyToMono(MemberCard.class)) if (adminPassword == null || adminPassword.isBlank()) {
.flatMap(tuple -> { return ServerResponse.badRequest()
MemberCard existing = tuple.getT1(); .contentType(MediaType.APPLICATION_JSON)
MemberCard body = tuple.getT2(); .bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
// 仅更新非 null 字段,避免 null 覆盖已有值 .flatMap(resp -> Mono.just(resp));
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName()); }
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice()); return sysUserService.verifyPassword(adminId, adminPassword)
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays()); .flatMap(valid -> {
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes()); if (!valid) {
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount()); return ServerResponse.status(HttpStatus.FORBIDDEN)
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus()); .contentType(MediaType.APPLICATION_JSON)
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig()); .bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
return memberCardService.save(existing); .flatMap(resp -> Mono.just(resp));
}) }
.flatMap(updated -> ServerResponse.ok().bodyValue(updated)); return request.bodyToMono(MemberCard.class)
.flatMap(body -> memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(existing -> {
existing.setMemberCardName(body.getMemberCardName());
existing.setMemberCardType(body.getMemberCardType());
existing.setMemberCardPrice(body.getMemberCardPrice());
existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
existing.setMemberCardAmount(body.getMemberCardAmount());
existing.setMemberCardStatus(body.getMemberCardStatus());
return memberCardService.save(existing);
})
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build()));
});
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型,需验证管理员密码")
public Mono<ServerResponse> deleteMemberCard(ServerRequest request) { public Mono<ServerResponse> deleteMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return memberCardService.logicalDelete(id) String adminPassword = request.queryParam("adminPassword").orElse(null);
.flatMap(rows -> {
if (rows > 0) { if (adminPassword == null || adminPassword.isBlank()) {
return ServerResponse.noContent().build(); return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
} }
return ServerResponse.notFound().build(); return memberCardService.logicalDelete(id)
.flatMap(rows -> {
if (rows > 0) {
return ServerResponse.noContent().build();
}
return ServerResponse.notFound().build();
});
}); });
} }
@@ -1,7 +1,7 @@
package cn.novalon.gym.manage.member.handler; package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.common.exception.NotFoundException; import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.config.WechatProperties; import cn.novalon.gym.manage.member.dto.AdminEditMemberDto;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto; import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
@@ -10,8 +10,8 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.service.WechatOfficialService; import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil; import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -24,6 +24,8 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员信息处理器 * 会员信息处理器
* *
@@ -41,6 +43,7 @@ public class MemberHandler {
private final WechatAuthService wechatAuthService; private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService; private final WechatOfficialService wechatOfficialService;
private final AuthUtil authUtil; private final AuthUtil authUtil;
private final ISysUserService sysUserService;
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) { public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
@@ -155,8 +158,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(detail.getPhone()); String decryptedPhone = AesUtil.decrypt(detail.getPhone());
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId()); log.error("手机号解密失败, memberId: {}", detail.getId(), e);
detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone())); detail.setPhone(null);
} }
} }
return ServerResponse.ok() return ServerResponse.ok()
@@ -165,7 +168,7 @@ public class MemberHandler {
}); });
} }
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息") @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息,需验证管理员密码")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getMemberIdOrThrow(request);
@@ -174,14 +177,30 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误"); if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId); log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
return request.bodyToMono(UpdateMemberInfoDto.class) return request.bodyToMono(AdminEditMemberDto.class)
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto)) .flatMap(dto -> {
.flatMap(detail -> ServerResponse.ok() if (dto.getAdminPassword() == null || dto.getAdminPassword().isBlank()) {
.contentType(MediaType.APPLICATION_JSON) return ServerResponse.badRequest()
.bodyValue(detail)); .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return sysUserService.verifyPassword(adminId, dto.getAdminPassword())
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return memberService.adminUpdateMemberInfo(memberId, dto.toUpdateMemberInfoDto())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
});
});
} }
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
@@ -204,8 +223,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); log.error("手机号解密失败, memberId: {}", member.getId(), e);
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); member.setPhone(null);
} }
} }
return member; return member;
@@ -222,13 +241,11 @@ public class MemberHandler {
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
String sortField = request.queryParam("sortField").orElse(null);
String sortOrder = request.queryParam("sortOrder").orElse(null);
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}", log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
adminId, pageNum, pageSize, sortField, sortOrder); // TODO: 补充签到记录
return memberService.findAll(pageNum, pageSize, sortField, sortOrder) return memberService.findAll(pageNum, pageSize)
.map(member -> { .map(member -> {
// 解密手机号 // 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) { if (member.getPhone() != null && !member.getPhone().isEmpty()) {
@@ -236,8 +253,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); log.error("手机号解密失败, memberId: {}", member.getId(), e);
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); member.setPhone(null);
} }
} }
return member; return member;
@@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
"AND (:type IS NULL OR member_card_type = :type) " + "AND (:type IS NULL OR member_card_type = :type) " +
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " + "AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
"ORDER BY id ASC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") "ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
Flux<MemberCard> findWithConditions(Integer status, String name, String type, Flux<MemberCard> findWithConditions(Integer status, String name, String type,
Double minPrice, Double maxPrice, Pageable pageable); Double minPrice, Double maxPrice, Pageable pageable);
@@ -58,7 +58,7 @@ public interface MemberService {
* @param pageSize 页大小 * @param pageSize 页大小
* @return 所有会员信息 * @return 所有会员信息
*/ */
Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder); Flux<Member> findAll(Integer pageNum, Integer pageSize);
/** /**
* 前台管理端获取会员详情(含会员卡信息) * 前台管理端获取会员详情(含会员卡信息)
@@ -26,7 +26,6 @@ import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -158,7 +157,6 @@ public class MemberServiceImpl implements MemberService {
return MemberInfoVO.builder() return MemberInfoVO.builder()
.id(member.getId()) .id(member.getId())
.memberNo(member.getMemberNo())
.nickname(member.getNickname()) .nickname(member.getNickname())
.phone(maskedPhone) .phone(maskedPhone)
.gender(genderEnum) .gender(genderEnum)
@@ -167,7 +165,6 @@ public class MemberServiceImpl implements MemberService {
.avatar(member.getAvatar()) .avatar(member.getAvatar())
.hasPhone(phone != null) .hasPhone(phone != null)
.isSubscribed(member.getSubscribed() != null && member.getSubscribed()) .isSubscribed(member.getSubscribed() != null && member.getSubscribed())
.lastLoginAt(member.getLastLoginAt())
.build(); .build();
} }
@@ -233,31 +230,13 @@ public class MemberServiceImpl implements MemberService {
); );
} }
/** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */
private static final java.util.Map<String, String> ALLOWED_SORT_FIELDS = java.util.Map.of(
"createdAt", "created_at",
"memberNo", "member_no",
"gender", "gender",
"id", "id"
);
@Override @Override
public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) { public Flux<Member> findAll(Integer pageNum, Integer pageSize) {
log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}", log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize);
pageNum, pageSize, sortField, sortOrder);
// 排序方向:desc(默认) / asc
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC : Sort.Direction.DESC;
// 排序字段白名单校验,不在白名单中则使用默认排序
String dbField = (sortField != null && ALLOWED_SORT_FIELDS.containsKey(sortField))
? ALLOWED_SORT_FIELDS.get(sortField) : "created_at";
Pageable pageable = PageRequest.of( Pageable pageable = PageRequest.of(
pageNum - 1, pageNum - 1,
pageSize, pageSize
Sort.by(direction, dbField)
); );
return memberRepository.findAllBy(pageable); return memberRepository.findAllBy(pageable);
@@ -270,51 +249,63 @@ public class MemberServiceImpl implements MemberService {
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
return redisUtil.get(cacheKey, MemberDetailVO.class) return redisUtil.get(cacheKey, MemberDetailVO.class)
.filter(cached -> cached != null) .flatMap(cached -> {
.switchIfEmpty(Mono.defer(() -> if (cached != null) {
memberRepository.findById(memberId) log.debug("从缓存获取会员详情, memberId: {}", memberId);
.switchIfEmpty(Mono.error(() -> { return Mono.just(cached);
log.error("会员不存在: memberId={}", memberId); }
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); // 缓存反序列化异常,查数据库
})) return queryMemberDetailFromDb(memberId, cacheKey);
.zipWith( })
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) .switchIfEmpty(Mono.defer(() -> {
.collectList(), // 缓存不存在,查数据库
(baseInfo, cardList) -> { return queryMemberDetailFromDb(memberId, cacheKey);
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); }));
}
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); private Mono<MemberDetailVO> queryMemberDetailFromDb(Long memberId, String cacheKey) {
memberDetailVO.setGenderDesc(genderEnum.getDesc()); return memberRepository.findById(memberId)
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
List<MemberCardInfoVO> enrichedCards = cardList.stream() GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
.peek(vo -> { memberDetailVO.setGenderDesc(genderEnum.getDesc());
if (vo.getMemberCardType() != null) {
try {
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
}
if (vo.getMemberCardStatus() != null) {
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
}
})
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
long activeCount = enrichedCards.stream() List<MemberCardInfoVO> enrichedCards = cardList.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) .peek(vo -> {
.count(); if (vo.getMemberCardType() != null) {
memberDetailVO.setActiveCardCount((int) activeCount); try {
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
return memberDetailVO; } catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
} }
) if (vo.getMemberCardStatus() != null) {
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
.then(Mono.just(vo))) }
)); })
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
long activeCount = enrichedCards.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
.count();
memberDetailVO.setActiveCardCount((int) activeCount);
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
return memberDetailVO;
}
)
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo)))
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
return new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
} }
@@ -43,25 +43,12 @@ public class WechatApiServiceImpl implements WechatApiService {
@Override @Override
public Mono<Map<String, String>> jsCode2Session(String code) { public Mono<Map<String, String>> jsCode2Session(String code) {
log.info("微信jsCode2Session API, code: {}", code); log.info("微信jsCode2Session API");
log.info("信息 - AppID: {}, AppSecret {}",
// Mock模式:返回模拟数据
if (wechatProperties.getMockEnabled() != null && wechatProperties.getMockEnabled()) {
log.info("Mock模式已启用,返回模拟微信登录数据");
Map<String, String> mockResult = new HashMap<>();
// 使用 code 的哈希生成唯一的 openid,确保每次登录创建不同用户
String mockOpenId = "mock_openid_" + Math.abs(code.hashCode() % 1000000);
mockResult.put("openid", mockOpenId);
mockResult.put("session_key", "mock_session_key");
mockResult.put("unionid", "mock_unionid_" + Math.abs(code.hashCode() % 1000000));
log.info("Mock微信API响应成功, openid: {}, unionid: {}", mockOpenId, mockResult.get("unionid"));
return Mono.just(mockResult);
}
log.info("真实微信API调用 - AppID: {}, AppSecret {}",
wechatProperties.getMiniapp().getAppId(), wechatProperties.getMiniapp().getAppId(),
wechatProperties.getMiniapp().getAppSecret() != null ? wechatProperties.getMiniapp().getAppSecret() != null ?
wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null"); wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null");
log.info(" - code: {}", code);
return webClient.get() return webClient.get()
.uri(uriBuilder -> uriBuilder .uri(uriBuilder -> uriBuilder
@@ -165,12 +152,6 @@ public class WechatApiServiceImpl implements WechatApiService {
public Mono<String> getAccessToken(String appType) { public Mono<String> getAccessToken(String appType) {
log.debug("获取access_token, appType: {}", appType); log.debug("获取access_token, appType: {}", appType);
// Mock模式:返回模拟access_token
if (wechatProperties.getMockEnabled() != null && wechatProperties.getMockEnabled()) {
log.debug("Mock模式已启用,返回模拟access_token");
return Mono.just("mock_access_token");
}
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType; String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType;
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
@@ -7,7 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date; import java.util.Date;
/** /**
@@ -26,9 +25,6 @@ public class MemberInfoVO {
// 会员 ID // 会员 ID
private Long id; private Long id;
// 会员编号
private String memberNo;
// 昵称 // 昵称
private String nickname; private String nickname;
@@ -52,7 +48,4 @@ public class MemberInfoVO {
// 是否已关注公众号 // 是否已关注公众号
private Boolean isSubscribed; private Boolean isSubscribed;
// 最后登录时间
private LocalDateTime lastLoginAt;
} }
@@ -0,0 +1,54 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付记录响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "支付记录")
public class PaymentRecordResponse {
@Schema(description = "订单ID")
private Long id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "用户编号(会员ID)")
private Long memberId;
@Schema(description = "支付方式(ALIPAY/WECHAT)")
private String tradeType;
@Schema(description = "购买内容(商品描述)")
private String goodsDesc;
@Schema(description = "订单类型(MEMBER_CARD/GROUP_COURSE/GOODS)")
private String orderType;
@Schema(description = "支付金额(元)")
private BigDecimal transAmt;
@Schema(description = "支付状态(PENDING/SUCCESS/FAIL/CLOSED)")
private String payStatus;
@Schema(description = "支付时间")
private String payTime;
@Schema(description = "汇付流水号")
private String hfSeqId;
@Schema(description = "创建时间")
private String createdAt;
}
@@ -0,0 +1,47 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 营业额统计数据
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "营业额统计数据")
public class RevenueStatistics {
@Schema(description = "今日收入(元)")
private BigDecimal todayIncome;
@Schema(description = "今日退款(元)")
private BigDecimal todayRefund;
@Schema(description = "今日订单数")
private Long todayOrderCount;
@Schema(description = "当月收入(元)")
private BigDecimal monthIncome;
@Schema(description = "当月退款(元)")
private BigDecimal monthRefund;
@Schema(description = "当月订单数")
private Long monthOrderCount;
@Schema(description = "当年收入(元)")
private BigDecimal yearIncome;
@Schema(description = "当年退款(元)")
private BigDecimal yearRefund;
@Schema(description = "当年订单数")
private Long yearOrderCount;
}
@@ -0,0 +1,74 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.ApiResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
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;
/**
* 支付营业数据 Handler
*
* @author system
* @date 2026-06-29
*/
@Slf4j
@Component
@Tag(name = "支付营业数据", description = "营业额统计与支付记录查询")
public class PaymentRevenueHandler {
private final PaymentService paymentService;
public PaymentRevenueHandler(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "获取营业额统计", description = "获取今日、当月、当年的收入和退款情况")
public Mono<ServerResponse> getRevenueStatistics(ServerRequest request) {
log.info("[Revenue] 查询营业额统计");
return paymentService.getRevenueStatistics()
.flatMap(stats -> ServerResponse.ok()
.bodyValue(ApiResponse.success(stats)))
.onErrorResume(e -> {
log.error("[Revenue] 查询营业额统计失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询营业额统计失败: " + e.getMessage()));
});
}
@Operation(summary = "查询支付记录", description = "分页查询用户支付记录,支持按会员ID、支付状态、支付方式筛选")
public Mono<ServerResponse> getPaymentRecords(ServerRequest request) {
String memberIdStr = request.queryParam("memberId").orElse(null);
Long memberId = null;
if (memberIdStr != null && !memberIdStr.isEmpty()) {
try {
memberId = Long.parseLong(memberIdStr);
} catch (NumberFormatException e) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error("会员ID格式不正确"));
}
}
String payStatus = request.queryParam("payStatus").orElse(null);
String tradeType = request.queryParam("tradeType").orElse(null);
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
int pageSize = Integer.parseInt(request.queryParam("pageSize").orElse("20"));
log.info("[Revenue] 查询支付记录: memberId={}, payStatus={}, tradeType={}, page={}, pageSize={}",
memberId, payStatus, tradeType, page, pageSize);
return paymentService.getPaymentRecords(memberId, payStatus, tradeType, page, pageSize)
.flatMap(result -> ServerResponse.ok()
.bodyValue(ApiResponse.success(result)))
.onErrorResume(e -> {
log.error("[Revenue] 查询支付记录失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询支付记录失败: " + e.getMessage()));
});
}
}
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Repository @Repository
@@ -38,4 +39,24 @@ public interface PaymentOrderRepository extends R2dbcRepository<PaymentOrder, Lo
Flux<PaymentOrder> findAllByDeletedAtIsNull(); Flux<PaymentOrder> findAllByDeletedAtIsNull();
Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId); Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId);
// ===== 营业额统计 =====
/** 统计指定时间范围内成功支付的金额总和 */
@Query("SELECT COALESCE(SUM(trans_amt), 0) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<BigDecimal> sumSuccessAmount(LocalDateTime startTime, LocalDateTime endTime);
/** 统计指定时间范围内的成功订单数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<Long> countSuccessOrders(LocalDateTime startTime, LocalDateTime endTime);
// ===== 支付记录分页查询 =====
/** 按条件分页查询支付记录 */
@Query("SELECT * FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
Flux<PaymentOrder> findPaymentRecords(Long memberId, String payStatus, String tradeType, int limit, int offset);
/** 按条件统计支付记录总数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL")
Mono<Long> countPaymentRecords(Long memberId, String payStatus, String tradeType);
} }
@@ -1,7 +1,9 @@
package cn.novalon.gym.manage.payment.service; package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -30,4 +32,10 @@ public interface PaymentService {
Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType); Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType);
Mono<Boolean> closeOrder(Long memberId, String orderId); Mono<Boolean> closeOrder(Long memberId, String orderId);
/** 获取营业额统计(今日/当月/当年) */
Mono<RevenueStatistics> getRevenueStatistics();
/** 分页查询支付记录 */
Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize);
} }
@@ -3,7 +3,9 @@ package cn.novalon.gym.manage.payment.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.payment.config.HuifuProperties; import cn.novalon.gym.manage.payment.config.HuifuProperties;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import cn.novalon.gym.manage.payment.entity.PaymentOrder; import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository; import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository;
import cn.novalon.gym.manage.payment.service.PaymentNotifyService; import cn.novalon.gym.manage.payment.service.PaymentNotifyService;
@@ -934,4 +936,76 @@ public class PaymentServiceImpl implements PaymentService {
}) })
.switchIfEmpty(Mono.just(false)); .switchIfEmpty(Mono.just(false));
} }
@Override
public Mono<RevenueStatistics> getRevenueStatistics() {
LocalDateTime now = LocalDateTime.now();
// 今日
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 当月
LocalDateTime monthStart = now.toLocalDate().withDayOfMonth(1).atStartOfDay();
LocalDateTime monthEnd = monthStart.plusMonths(1);
// 当年
LocalDateTime yearStart = now.toLocalDate().withDayOfYear(1).atStartOfDay();
LocalDateTime yearEnd = yearStart.plusYears(1);
Mono<BigDecimal> todayIncomeMono = paymentOrderRepository.sumSuccessAmount(todayStart, todayEnd);
Mono<Long> todayCountMono = paymentOrderRepository.countSuccessOrders(todayStart, todayEnd);
Mono<BigDecimal> monthIncomeMono = paymentOrderRepository.sumSuccessAmount(monthStart, monthEnd);
Mono<Long> monthCountMono = paymentOrderRepository.countSuccessOrders(monthStart, monthEnd);
Mono<BigDecimal> yearIncomeMono = paymentOrderRepository.sumSuccessAmount(yearStart, yearEnd);
Mono<Long> yearCountMono = paymentOrderRepository.countSuccessOrders(yearStart, yearEnd);
return Mono.zip(todayIncomeMono, todayCountMono, monthIncomeMono, monthCountMono, yearIncomeMono, yearCountMono)
.map(tuple -> RevenueStatistics.builder()
.todayIncome(tuple.getT1() != null ? tuple.getT1() : BigDecimal.ZERO)
.todayRefund(BigDecimal.ZERO) // 退款功能暂未实现
.todayOrderCount(tuple.getT2() != null ? tuple.getT2() : 0L)
.monthIncome(tuple.getT3() != null ? tuple.getT3() : BigDecimal.ZERO)
.monthRefund(BigDecimal.ZERO) // 退款功能暂未实现
.monthOrderCount(tuple.getT4() != null ? tuple.getT4() : 0L)
.yearIncome(tuple.getT5() != null ? tuple.getT5() : BigDecimal.ZERO)
.yearRefund(BigDecimal.ZERO) // 退款功能暂未实现
.yearOrderCount(tuple.getT6() != null ? tuple.getT6() : 0L)
.build());
}
@Override
public Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize) {
int offset = (page - 1) * pageSize;
Mono<List<PaymentRecordResponse>> recordsMono = paymentOrderRepository
.findPaymentRecords(memberId, payStatus, tradeType, pageSize, offset)
.map(order -> PaymentRecordResponse.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.memberId(order.getMemberId())
.tradeType(order.getTradeType())
.goodsDesc(order.getGoodsDesc())
.orderType(order.getOrderType())
.transAmt(order.getTransAmt())
.payStatus(order.getPayStatus())
.payTime(order.getPayTime() != null
? order.getPayTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.hfSeqId(order.getHfSeqId())
.createdAt(order.getCreatedAt() != null
? order.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.build())
.collectList();
Mono<Long> totalMono = paymentOrderRepository.countPaymentRecords(memberId, payStatus, tradeType);
return Mono.zip(recordsMono, totalMono)
.map(tuple -> {
Map<String, Object> result = new HashMap<>();
result.put("records", tuple.getT1());
result.put("total", tuple.getT2());
result.put("page", page);
result.put("pageSize", pageSize);
return result;
});
}
} }
+1991 -2915
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -169,12 +169,6 @@
<version>1.0.0</version> <version>1.0.0</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -25,8 +25,7 @@ import java.util.List;
"cn.novalon.gym.manage.member.repository", "cn.novalon.gym.manage.member.repository",
"cn.novalon.gym.manage.groupcourse.dao", "cn.novalon.gym.manage.groupcourse.dao",
"cn.novalon.gym.manage.checkIn.repository", "cn.novalon.gym.manage.checkIn.repository",
"cn.novalon.gym.manage.payment.repository", "cn.novalon.gym.manage.payment.repository"
"cn.novalon.gym.manage.coach.dao"
}) })
@EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository") @EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository")
public class ManageApplication { public class ManageApplication {
@@ -7,21 +7,21 @@ import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler; import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.BannerHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler; import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.groupcourse.handler.CommonUploadHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
import cn.novalon.gym.manage.member.handler.MemberHandler; import cn.novalon.gym.manage.member.handler.MemberHandler;
import cn.novalon.gym.manage.member.handler.MemberStoredCardHandler; import cn.novalon.gym.manage.member.handler.MemberStoredCardHandler;
import cn.novalon.gym.manage.member.handler.WechatAuthHandler; import cn.novalon.gym.manage.member.handler.WechatAuthHandler;
import cn.novalon.gym.manage.notify.handler.BannerHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler; import cn.novalon.gym.manage.payment.handler.PaymentRevenueHandler;
import cn.novalon.gym.manage.coach.handler.CoachHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -66,7 +66,6 @@ public class SystemRouter {
SysAuthHandler authHandler, SysAuthHandler authHandler,
StatsHandler statsHandler, StatsHandler statsHandler,
SysDictHandler dictHandler, SysDictHandler dictHandler,
BannerHandler bannerHandler,
SysNoticeHandler noticeHandler, SysNoticeHandler noticeHandler,
SysUserMessageHandler messageHandler, SysUserMessageHandler messageHandler,
SysFileHandler fileHandler, SysFileHandler fileHandler,
@@ -83,12 +82,13 @@ public class SystemRouter {
GroupCourseRecommendHandler groupCourseRecommendHandler, GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler, GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler, CourseLabelHandler courseLabelHandler,
BannerHandler bannerHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler, DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler, PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler, PaymentHandler paymentHandler,
CoachHandler coachHandler, PaymentRevenueHandler paymentRevenueHandler,
CoachCourseHandler coachCourseHandler) { CommonUploadHandler commonUploadHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由 ==========
@@ -122,17 +122,6 @@ public class SystemRouter {
.GET("/api/users/{id}/roles", userHandler::getUserRoles) .GET("/api/users/{id}/roles", userHandler::getUserRoles)
.POST("/api/users/{id}/roles", userHandler::assignRoles) .POST("/api/users/{id}/roles", userHandler::assignRoles)
// ========== 教练路由 ==========
.GET("/api/coach/list", coachHandler::getAllCoaches)
.POST("/api/coach", coachHandler::createCoach)
.PUT("/api/coach/{id}", coachHandler::updateCoach)
.POST("/api/coach/{id}/disable", coachHandler::disableCoach)
.GET("/api/coach/{id}/courses", coachHandler::getCoachCourses)
.GET("/api/coach/violation-counts", coachHandler::getViolationCounts)
.GET("/api/coach/{id}/violations", coachHandler::getCoachViolations)
.POST("/api/coach/courses/{courseId}/start", coachCourseHandler::startCourse)
.POST("/api/coach/courses/{courseId}/end", coachCourseHandler::endCourse)
// ========== 菜单路由 ========== // ========== 菜单路由 ==========
.GET("/api/menus", menuHandler::getAllMenus) .GET("/api/menus", menuHandler::getAllMenus)
.GET("/api/menus/tree", menuHandler::getMenuTree) .GET("/api/menus/tree", menuHandler::getMenuTree)
@@ -187,6 +176,7 @@ public class SystemRouter {
.POST("/api/auth/login", authHandler::login) .POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register) .POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout) .POST("/api/auth/logout", authHandler::logout)
.GET("/api/auth/me", authHandler::me)
// ========== 统计路由 ========== // ========== 统计路由 ==========
.GET("/api/stats/overview", statsHandler::getOverview) .GET("/api/stats/overview", statsHandler::getOverview)
@@ -205,14 +195,6 @@ public class SystemRouter {
.PUT("/api/dict/data/{id}", dictHandler::updateDictData) .PUT("/api/dict/data/{id}", dictHandler::updateDictData)
.DELETE("/api/dict/data/{id}", dictHandler::deleteDictData) .DELETE("/api/dict/data/{id}", dictHandler::deleteDictData)
// ========== 轮播图路由 ==========
.GET("/api/banners", bannerHandler::getAllBanners)
.GET("/api/banners/active", bannerHandler::getActiveBanners)
.GET("/api/banners/{id}", bannerHandler::getBannerById)
.POST("/api/banners", bannerHandler::createBanner)
.PUT("/api/banners/{id}", bannerHandler::updateBanner)
.DELETE("/api/banners/{id}", bannerHandler::deleteBanner)
// ========== 公告路由 ========== // ========== 公告路由 ==========
.GET("/api/notices", noticeHandler::getAllNotices) .GET("/api/notices", noticeHandler::getAllNotices)
.GET("/api/notices/{id}", noticeHandler::getNoticeById) .GET("/api/notices/{id}", noticeHandler::getNoticeById)
@@ -242,6 +224,10 @@ public class SystemRouter {
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile) .DELETE("/api/files/{id}", fileHandler::deleteFile)
// ===== 通用文件上传OSS=====
.POST("/api/upload/image", commonUploadHandler::uploadImage)
.GET("/api/upload/presign", commonUploadHandler::presignUrl)
// ========== 权限路由 ========== // ========== 权限路由 ==========
.GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById) .GET("/api/permissions/{id}", permissionHandler::getPermissionById)
@@ -333,7 +319,6 @@ public class SystemRouter {
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory) .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById) .GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType) .POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
.POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage)
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType) .PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType) .DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
@@ -342,7 +327,6 @@ public class SystemRouter {
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels) .GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById) .GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId) .GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
.POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage)
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel) .POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel) .PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel) .DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
@@ -368,16 +352,28 @@ public class SystemRouter {
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation) .POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation) .POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ========== 轮播图路由 ==========
.GET("/api/banner/list", bannerHandler::getAllBanners)
.GET("/api/banner/active", bannerHandler::getAllActiveBanners)
.GET("/api/banner/{id}", bannerHandler::getBannerById)
.POST("/api/banner", bannerHandler::createBanner)
.PUT("/api/banner/{id}", bannerHandler::updateBanner)
.DELETE("/api/banner/{id}", bannerHandler::deleteBanner)
.POST("/api/banner/{id}/enable", bannerHandler::enableBanner)
.POST("/api/banner/{id}/disable", bannerHandler::disableBanner)
// ===== 团课课程管理需要放在具体路由之后===== // ===== 团课课程管理需要放在具体路由之后=====
.GET("/api/groupCourse/{id}/qrcode", groupCourseHandler::getCourseQRCode)
.POST("/api/groupCourse/{courseId}/qrsignin", groupCourseBookingHandler::qrSignIn)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById) .GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById) .GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) .POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse) .PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse) .DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{id}/restore", groupCourseHandler::restoreGroupCourse)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn) .POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses) .POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
.POST("/api/groupCourse/check-coach-conflict", groupCourseHandler::checkCoachConflict)
// ========= 签到模块路由 ========== // ========= 签到模块路由 ==========
// ===== 签到核心功能 ===== // ===== 签到核心功能 =====
@@ -385,15 +381,17 @@ public class SystemRouter {
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode) .GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 ===== // ===== 签到记录管理 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/records", checkInHandler::getSignInRecords) .GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById) .GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
// ===== 签到统计 ===== // ===== 签到统计 =====
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics) .GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats) .GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 签到数据导出 ===== // ===== 管理员签到记录全员 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords) .GET("/api/checkIn/admin/records", checkInHandler::getAllSignInRecords)
.GET("/api/checkIn/admin/statistics", checkInHandler::getAllSignInStatistics)
// ======================================== // ========================================
// ========== 数据统计模块路由 ============ // ========== 数据统计模块路由 ============
@@ -422,6 +420,10 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment) .POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder) .POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ===== 支付营业数据 =====
.GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics)
.GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords)
.build(); .build();
} }
} }
@@ -1,18 +1,18 @@
# 微信配置(测试环境使用模拟数据) # 微信配置(测试环境使用模拟数据)
wechat: wechat:
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境) # Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
mock-enabled: true mock-enabled: false
miniapp: miniapp:
app-id: ${WECHAT_MINIAPP_APP_ID:} app-id: ${WECHAT_MINIAPP_APP_ID}
app-secret: ${WECHAT_MINIAPP_SECRET:} app-secret: ${WECHAT_MINIAPP_SECRET}
mp: mp:
app-id: ${WECHAT_MP_APP_ID:} app-id: ${WECHAT_MP_APP_ID}
app-secret: ${WECHAT_MP_SECRET:} app-secret: ${WECHAT_MP_SECRET}
token: ${WECHAT_MP_TOKEN:} token: ${WECHAT_MP_TOKEN}
aes-key: ${WECHAT_MP_AESKEY:} aes-key: ${WECHAT_MP_AESKEY}
callback-url: ${WECHAT_MP_CALLBACK_URL:} callback-url: ${WECHAT_MP_CALLBACK_URL}
# 手机号加密配置(AES-128-CBC16字节密钥和IVBase64编码) # 手机号加密配置(AES-128-CBC16字节密钥和IVBase64编码)
phone-encryption: phone-encryption:
+4
View File
@@ -60,6 +60,10 @@
<groupId>org.springframework.data</groupId> <groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId> <artifactId>spring-data-redis</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -3,7 +3,6 @@ package cn.novalon.gym.manage.common.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
@@ -12,9 +11,6 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali
import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/** /**
* Redis 配置类响应式版本 * Redis 配置类响应式版本
* *
@@ -24,41 +20,30 @@ import java.time.format.DateTimeFormatter;
@Configuration @Configuration
public class RedisConfig { public class RedisConfig {
/**
* 创建支持 Java 8 时间类型与类型信息的 ObjectMapper用于 Redis JSON 序列化
*/
private ObjectMapper createRedisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
mapper.registerModule(javaTimeModule);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 启用默认类型信息确保反序列化时能还原为原始 Java 类型
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
return mapper;
}
/** /**
* 配置 ReactiveRedisTemplate * 配置 ReactiveRedisTemplate
*/ */
@Bean @Bean
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate( public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
ReactiveRedisConnectionFactory connectionFactory) { ReactiveRedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
GenericJackson2JsonRedisSerializer jsonSerializer = GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
new GenericJackson2JsonRedisSerializer(createRedisObjectMapper());
// 配置序列化上下文 // 配置序列化上下文
RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext<String, Object> serializationContext =
RedisSerializationContext.<String, Object>newSerializationContext() RedisSerializationContext.<String, Object>newSerializationContext()
.key(StringRedisSerializer.UTF_8) .key(StringRedisSerializer.UTF_8)
.value(jsonSerializer) .value(serializer)
.hashKey(StringRedisSerializer.UTF_8) .hashKey(StringRedisSerializer.UTF_8)
.hashValue(jsonSerializer) .hashValue(serializer)
.build(); .build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
@@ -12,8 +12,6 @@ public class PageRequest {
private String sort = "id"; private String sort = "id";
private String order = "asc"; private String order = "asc";
private String keyword; private String keyword;
private String status;
private String category;
public int getPage() { public int getPage() {
return page; return page;
@@ -54,20 +52,4 @@ public class PageRequest {
public void setKeyword(String keyword) { public void setKeyword(String keyword) {
this.keyword = keyword; this.keyword = keyword;
} }
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
} }
@@ -1,6 +1,5 @@
package cn.novalon.gym.manage.common.util; package cn.novalon.gym.manage.common.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -14,7 +13,6 @@ import java.time.Duration;
* @author liwentao * @author liwentao
* @date 2026/5/15 * @date 2026/5/15
*/ */
@Slf4j
@Component @Component
public class RedisUtil { public class RedisUtil {
@@ -36,19 +34,12 @@ public class RedisUtil {
} }
/** /**
* 获取值带类型转换 * 获取值
* filter+cast 替代 map 避免 NPEonErrorResume 兼容旧缓存格式反序列化失败
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> Mono<T> get(String key, Class<T> clazz) { public <T> Mono<T> get(String key, Class<T> clazz) {
return reactiveRedisTemplate.opsForValue().get(key) return reactiveRedisTemplate.opsForValue().get(key)
.filter(clazz::isInstance) .map(obj -> clazz.isInstance(obj) ? (T) obj : null);
.map(clazz::cast)
.onErrorResume(e -> {
// 旧缓存数据格式不兼容时静默跳过后续逻辑会 fallback DB 查询
log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage());
return Mono.empty();
});
} }
/** /**
@@ -1,73 +0,0 @@
package cn.novalon.gym.manage.db.converter;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* 轮播图实体转换器
*
* @author 张翔
* @date 2026-07-18
*/
@Component
public class BannerConverter {
public Banner toDomain(BannerEntity entity) {
if (entity == null) {
return null;
}
Banner domain = new Banner();
domain.setId(entity.getId());
domain.setImageUrl(entity.getImageUrl());
domain.setTitle(entity.getTitle());
domain.setSubtitle(entity.getSubtitle());
domain.setSortOrder(entity.getSortOrder());
domain.setIsActive(entity.getIsActive());
domain.setLinkUrl(entity.getLinkUrl());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());
domain.setDeletedAt(entity.getDeletedAt());
return domain;
}
public BannerEntity toEntity(Banner domain) {
if (domain == null) {
return null;
}
BannerEntity entity = new BannerEntity();
entity.setId(domain.getId());
entity.setImageUrl(domain.getImageUrl());
entity.setTitle(domain.getTitle());
entity.setSubtitle(domain.getSubtitle());
entity.setSortOrder(domain.getSortOrder());
entity.setIsActive(domain.getIsActive());
entity.setLinkUrl(domain.getLinkUrl());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
entity.setDeletedAt(domain.getDeletedAt());
return entity;
}
public List<Banner> toDomainList(List<BannerEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
public List<BannerEntity> toEntityList(List<Banner> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
}
@@ -1,22 +0,0 @@
package cn.novalon.gym.manage.db.dao;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Flux<BannerEntity> findByDeletedAtIsNull();
Flux<BannerEntity> findByDeletedAtIsNull(Sort sort);
@Query("SELECT * FROM banner WHERE deleted_at IS NULL ORDER BY sort_order ASC")
Flux<BannerEntity> findActiveBanners();
Mono<Void> deleteByIdAndDeletedAtIsNull(Long id);
}
@@ -1,127 +0,0 @@
package cn.novalon.gym.manage.db.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
/**
* 轮播图数据库实体类
*
* @author 张翔
* @date 2026-07-18
*/
@Table("banner")
public class BannerEntity {
@Id
private Long id;
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private String isActive;
@Column("link_url")
private String linkUrl;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Column("deleted_at")
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSubtitle() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle = subtitle;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public String getIsActive() {
return isActive;
}
public void setIsActive(String isActive) {
this.isActive = isActive;
}
public String getLinkUrl() {
return linkUrl;
}
public void setLinkUrl(String linkUrl) {
this.linkUrl = linkUrl;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}
@@ -1,59 +0,0 @@
package cn.novalon.gym.manage.db.repository;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.notify.core.repository.IBannerRepository;
import cn.novalon.gym.manage.db.converter.BannerConverter;
import cn.novalon.gym.manage.db.dao.BannerDao;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 轮播图仓储实现类
*
* @author 张翔
* @date 2026-07-18
*/
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final BannerConverter bannerConverter;
public BannerRepository(BannerDao bannerDao, BannerConverter bannerConverter) {
this.bannerDao = bannerDao;
this.bannerConverter = bannerConverter;
}
@Override
public Flux<Banner> findByDeletedAtIsNull() {
return bannerDao.findByDeletedAtIsNull(Sort.by(Sort.Direction.ASC, "sortOrder"))
.map(bannerConverter::toDomain);
}
@Override
public Flux<Banner> findActiveBanners() {
return bannerDao.findActiveBanners()
.map(bannerConverter::toDomain);
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findById(id)
.map(bannerConverter::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = bannerConverter.toEntity(banner);
return bannerDao.save(entity)
.map(bannerConverter::toDomain);
}
@Override
public Mono<Void> deleteByIdAndDeletedAtIsNull(Long id) {
return bannerDao.deleteByIdAndDeletedAtIsNull(id);
}
}
@@ -1,38 +0,0 @@
-- ============================================
-- 团课推荐表
-- 版本: V10
-- 描述: 创建团课推荐表,用于首页推荐团课展示
-- ============================================
CREATE TABLE IF NOT EXISTS group_course_recommend (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL,
recommend_title VARCHAR(200),
recommend_content TEXT,
recommend_reason VARCHAR(500),
priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_course_id ON group_course_recommend(course_id);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_priority ON group_course_recommend(priority);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_is_active ON group_course_recommend(is_active);
COMMENT ON TABLE group_course_recommend IS '团课推荐表';
COMMENT ON COLUMN group_course_recommend.id IS '主键ID';
COMMENT ON COLUMN group_course_recommend.course_id IS '团课ID(关联group_course.id';
COMMENT ON COLUMN group_course_recommend.recommend_title IS '推荐标题';
COMMENT ON COLUMN group_course_recommend.recommend_content IS '推荐内容';
COMMENT ON COLUMN group_course_recommend.recommend_reason IS '推荐理由';
COMMENT ON COLUMN group_course_recommend.priority IS '优先级(数字越大优先级越高)';
COMMENT ON COLUMN group_course_recommend.is_active IS '是否启用';
COMMENT ON COLUMN group_course_recommend.create_by IS '创建人';
COMMENT ON COLUMN group_course_recommend.update_by IS '更新人';
COMMENT ON COLUMN group_course_recommend.created_at IS '创建时间';
COMMENT ON COLUMN group_course_recommend.updated_at IS '更新时间';
COMMENT ON COLUMN group_course_recommend.deleted_at IS '删除时间(软删除)';

Some files were not shown because too many files have changed in this diff Show More