Author SHA1 Message Date
liwentao 53d1ce6fb2 更新会员端个人页面中团课签到记录显示 2026-07-20 17:27:22 +08:00
liwentao 4a4697c816 新增教练端,实现业务闭环:会员登录注册→查询团课→预约团课→扫码签到→教练端开课→记录实际开课时间→教练端结课→记录实际结课时间→后台查询数据 2026-07-20 17:21:28 +08:00
liwentao df0e68469b 新增教练管理,优化预约人数计数方式 2026-07-19 19:02:10 +08:00
liwentao 97a5eff678 完善业务闭环,实现对轮播图的管理,优化数据库表文件 2026-07-18 16:21:40 +08:00
liwentao feaf014b4a 补充缺失的文件 2026-07-17 16:37:59 +08:00
liwentao ff899f491e 实现用户登录→查询团课→预约团课→扫码签到→后台查看数据闭环 2026-07-17 16:36:25 +08:00
liwentao 922c5850b5 补充缺失的文件 2026-07-17 15:57:05 +08:00
liwentao 2e7c2ced43 实现大部分页面前后端互通 2026-07-17 15:48:36 +08:00
liwentao a9ccdab421 实现部分页面前后端相通 2026-07-16 17:29:27 +08:00
liwentao 7e45ecd144 重写会员端,初步完善首页 2026-07-15 17:26:07 +08:00
liwentao cacc8997ec 移除流程中的支付相关和会员卡相关 2026-07-14 18:49:15 +08:00
liwentao 593f6f13e7 新增数据统计页面 2026-07-10 13:56:16 +08:00
liwentao 8ee326b43a 修补缺失文件 2026-07-09 15:34:50 +08:00
liwentao 8fb4e714d8 新增会员卡管理 2026-07-09 15:33:05 +08:00
liwentao a8e69e0850 新增会员管理 2026-07-09 14:45:45 +08:00
liwentao f5b5724e92 完善团课相关管理 2026-07-08 17:02:04 +08:00
liwentao 1e41f31271 更新后台管理系统,实现团课管理基础显示 2026-07-06 16:44:27 +08:00
liwentao efd4d03037 修复多处问题 2026-06-29 02:56:13 +08:00
liwentao 80759f6793 修复一次支付发放两张会员卡的问题 2026-06-28 17:56:31 +08:00
liwentao f8279129be 修复编译时错误 2026-06-28 16:42:24 +08:00
281 changed files with 27234 additions and 2247 deletions
@@ -52,32 +52,24 @@ public class CheckServiceImpl implements ICheckInService {
@Override @Override
public Mono<QRCodeVo> getQRCode(Long memberId) { public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始查询会员信息, memberId: {}", memberId); log.info("开始生成会员签到二维码, memberId: {}", memberId);
return findValidMemberCard(memberId) String qrContent = QRRedisKey.generateQrcodeContent();
.flatMap(cardRecord -> { Map<String, Object> redisMap = new HashMap<>();
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId()); redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false);
log.info("开始生成二维码"); redisMap.put("memberId", memberId);
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
@@ -113,9 +105,10 @@ public class CheckServiceImpl implements ICheckInService {
} }
log.info("二维码匹配成功,memberId: {}", memberId); log.info("二维码匹配成功,memberId: {}", memberId);
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue(); Long memberCardRecordId = map.get("memberCardRecordId") != null
? ((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("二维码无效"));
@@ -145,54 +138,23 @@ public class CheckServiceImpl implements ICheckInService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知 // 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡..."); MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "正在检查预约信息...");
return memberCardRecordRepository.findById(memberCardRecordId) // 检查是否有需要签到的团课预约
.switchIfEmpty(Mono.defer(() -> { return validateBooking(memberId, now)
MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在"); .then(Mono.defer(() -> {
return Mono.error(new RuntimeException("会员卡记录不存在")); redisMap.put("isUsed", true);
})) redisMap.put("checkInTime", now.format(DATE_FORMATTER));
.flatMap(cardRecord -> {
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
return Mono.error(new RuntimeException("会员卡状态不正确"));
}
if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) { return saveSignInRecord(memberId, null, null)
MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期"); .then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
return Mono.error(new RuntimeException("会员卡已过期")); .then(Mono.defer(() -> {
} String successMsg = buildSuccessResponse(now);
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
// 发送实时进度通知 log.info("签到成功, memberId: {}", memberId);
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息..."); return Mono.just(successMsg);
}));
// 检查是否有需要签到的团课预约 }));
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);
}));
});
}));
});
} }
/** /**
@@ -127,7 +127,7 @@ class CheckInModuleTest {
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData)); when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord)); when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Flux.just(mockMemberCard)); when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard));
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord)); when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true)); when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty()); when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
+62
View File
@@ -0,0 +1,62 @@
<?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>
@@ -0,0 +1,15 @@
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> {
}
@@ -0,0 +1,61 @@
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;
}
}
@@ -0,0 +1,42 @@
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;
}
}
@@ -0,0 +1,30 @@
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;
}
}
@@ -0,0 +1,68 @@
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()));
});
}
}
@@ -0,0 +1,118 @@
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()));
}
}
@@ -0,0 +1,178 @@
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())
);
}
}
@@ -0,0 +1,357 @@
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();
}
}
+6 -1
View File
@@ -67,7 +67,12 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.2.5</version> </dependency>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency> </dependency>
<!-- Gym Modules --> <!-- Gym Modules -->
@@ -6,6 +6,7 @@ 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 执行跨表聚合查询
@@ -180,4 +181,68 @@ 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();
}
} }
@@ -0,0 +1,40 @@
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,6 +59,12 @@ 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,6 +37,11 @@ public class StatisticsSummary {
*/ */
private SignInStatistics signInStatistics; private SignInStatistics signInStatistics;
/**
* 教练统计数据
*/
private CoachStatistics coachStatistics;
/** /**
* 统计数据生成时间 * 统计数据生成时间
*/ */
@@ -171,18 +171,47 @@ 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); Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query); Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query); Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
Mono<CoachStatistics> coachStatsMono = getCoachStatistics(query);
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono) return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono, coachStatsMono)
.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());
} }
@@ -458,13 +487,16 @@ 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();
} }
} }
@@ -478,13 +510,14 @@ 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();
} }
} }
@@ -503,6 +536,15 @@ 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,11 +94,20 @@
<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>
@@ -61,6 +61,12 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
*/ */
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status); Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
/**
* 统计会员取消的预约次数
*/
@org.springframework.data.r2dbc.repository.Query("SELECT COUNT(*) FROM group_course_booking WHERE member_id = :memberId AND status = '1' AND deleted_at IS NULL")
Mono<Long> countCancelledByMemberId(Long memberId);
/** /**
* 查询会员是否有时间冲突的预约(状态为已预约且未取消) * 查询会员是否有时间冲突的预约(状态为已预约且未取消)
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间 * 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
@@ -108,4 +114,11 @@ 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,5 +1,6 @@
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;
@@ -32,60 +33,110 @@ 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 @Modifying
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL") @Query("UPDATE group_course SET status = :status, actual_start_time = :actualStartTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt); Mono<Integer> updateStartInfo(Long id, String status, LocalDateTime actualStartTime, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = :status, actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateEndInfo(Long id, String status, LocalDateTime actualEndTime, 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("SELECT * FROM group_course WHERE deleted_at IS NULL"); 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<>(); List<String> conditions = new ArrayList<>();
// 默认不查询可预约团课(status = '0' 且未过期) // 默认不查询可预约团课(status = '0' 且未过期)
conditions.add("status = '0'"); conditions.add("gc.status = '0'");
conditions.add("end_time > NOW()"); conditions.add("gc.end_time > NOW()");
// 1. 团课名称模糊查询 // 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) { if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName"); conditions.add("gc.course_name ILIKE :courseName");
} }
// 2. 基于团课类型查询 // 2. 基于团课类型查询
if (query.getCourseType() != null) { if (query.getCourseType() != null) {
conditions.add("course_type = :courseType"); conditions.add("gc.course_type = :courseType");
} }
// 3. 基于日期时间段查询 // 3. 基于日期时间段查询
if (query.getStartDate() != null) { if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate"); conditions.add("gc.start_time >= :startDate");
} }
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate"); conditions.add("gc.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 start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12"); conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 6 AND EXTRACT(HOUR FROM gc.start_time) < 12");
break; break;
case "afternoon": case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18"); conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 12 AND EXTRACT(HOUR FROM gc.start_time) < 18");
break; break;
case "evening": case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24"); conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 18 AND EXTRACT(HOUR FROM gc.start_time) < 24");
break; break;
default: default:
break; break;
@@ -103,20 +154,20 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
List<String> orderClauses = new ArrayList<>(); List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) { if (hasRemainingMost) {
orderClauses.add(" (max_members - current_members) DESC"); orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC");
} }
if (hasPriceSort) { if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) { if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount ASC"); orderClauses.add(" gc.stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) { } else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount DESC"); orderClauses.add(" gc.stored_value_amount DESC");
} }
} }
sql.append(String.join(",", orderClauses)); sql.append(String.join(",", orderClauses));
} else { } else {
sql.append(" ORDER BY start_time ASC"); sql.append(" ORDER BY gc.start_time ASC");
} }
// 分页 // 分页
@@ -144,29 +195,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
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) -> { return spec.map((row, meta) -> mapRowToEntity(row)).all();
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));
return entity;
}).all();
} }
/** /**
@@ -226,4 +255,123 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
return spec.map((row, meta) -> row.get(0, Long.class)).one(); return spec.map((row, meta) -> row.get(0, Long.class)).one();
} }
/**
* 分页查询团课(支持 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();
}
/**
* 统计单门课程的有效预约人数(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;
}
} }
@@ -40,6 +40,14 @@ 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;
@@ -124,6 +132,22 @@ 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;
} }
@@ -21,6 +21,9 @@ 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;
@@ -99,6 +102,14 @@ 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;
} }
@@ -38,7 +38,15 @@ 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;
@@ -158,6 +166,22 @@ 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;
} }
@@ -11,7 +11,10 @@ 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;
@@ -2,18 +2,18 @@ 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;
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.member.service.IMemberStoredCardService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -32,10 +32,9 @@ 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 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;
@@ -44,10 +43,10 @@ public class BookingSagaHandler {
* *
* 步骤: * 步骤:
* 1. 保存预约记录 * 1. 保存预约记录
* 2. 扣减会员卡权益 * 2. 扣减储值卡余额(储值卡类型)
* 3. 更新课程当前人数 * 3. 更新课程当前人数
*/ */
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId) { public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId, BigDecimal storedValueAmount) {
List<SagaStep> steps = new ArrayList<>(); List<SagaStep> steps = new ArrayList<>();
List<SagaStep> rollbackSteps = new ArrayList<>(); List<SagaStep> rollbackSteps = new ArrayList<>();
@@ -60,33 +59,15 @@ public class BookingSagaHandler {
steps.add(step1); steps.add(step1);
rollbackSteps.add(0, step1); rollbackSteps.add(0, step1);
// 步骤2:扣减会员卡权益(根据卡类型决定扣除次数还是金额 // 步骤2:扣减权益(根据卡类型)
SagaStep step2 = new SagaStep( SagaStep step2 = new SagaStep(
"扣减会员卡权益", "扣减权益",
deductCardUsageByCardType(booking.getMemberId(), recordId), deductCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount),
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId)) Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount))
); );
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));
} }
@@ -94,7 +75,7 @@ public class BookingSagaHandler {
/** /**
* 根据会员卡类型扣减权益 * 根据会员卡类型扣减权益
*/ */
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId) { private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
return memberCardRecordService.findById(recordId) return memberCardRecordService.findById(recordId)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
.flatMap(record -> { .flatMap(record -> {
@@ -109,14 +90,13 @@ public class BookingSagaHandler {
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
// 次数卡不再支持团课预约 return Mono.error(new RuntimeException("团课预约仅支持储值卡和时长卡支付"));
return Mono.error(new RuntimeException("团课预约仅支持储值卡支付"));
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
// 储值卡扣除金额 return deductStoredCardBalance(memberId, storedValueAmount);
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
case TIME_CARD: case TIME_CARD:
// 时长卡不扣除,但需验证有效期 // 时长卡验证有效期后,仍须从储值卡扣款
return validateTimeCard(record); return validateTimeCard(record)
.then(deductStoredCardBalance(memberId, storedValueAmount));
default: default:
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType)); return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
} }
@@ -153,11 +133,10 @@ public class BookingSagaHandler {
/** /**
* 根据会员卡类型恢复权益 * 根据会员卡类型恢复权益
*/ */
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId) { private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
return memberCardRecordService.findById(recordId) return memberCardRecordService.findById(recordId)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益"))) .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
.flatMap(record -> { .flatMap(record -> {
// 验证会员卡归属(memberId为null时跳过验证)
if (memberId != null && !record.getMemberId().equals(memberId)) { if (memberId != null && !record.getMemberId().equals(memberId)) {
return Mono.error(new RuntimeException("会员卡不归属当前用户")); return Mono.error(new RuntimeException("会员卡不归属当前用户"));
} }
@@ -168,12 +147,29 @@ public class BookingSagaHandler {
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
// 次数卡不再支持团课预约,无需恢复
return Mono.empty(); return Mono.empty();
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
return restoreCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE); // 返还储值卡余额(回滚时全额返还)
BigDecimal amount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
return memberStoredCardService.recharge(memberId, amount)
.flatMap(rows -> {
if (rows > 0) {
log.info("储值卡回滚返还成功: memberId={}, amount={}", memberId, amount);
return Mono.empty();
}
return Mono.empty();
});
case TIME_CARD: case TIME_CARD:
return Mono.empty(); // 时长卡回滚时也返还储值卡
BigDecimal timeAmount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
return memberStoredCardService.recharge(memberId, timeAmount)
.flatMap(rows -> {
if (rows > 0) {
log.info("储值卡回滚返还成功(时长卡): memberId={}, amount={}", memberId, timeAmount);
return Mono.empty();
}
return Mono.empty();
});
default: default:
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType)); return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
} }
@@ -184,7 +180,7 @@ public class BookingSagaHandler {
/** /**
* 执行取消预约事务 * 执行取消预约事务
*/ */
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId) { public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId, BigDecimal storedValueAmount, long cancelCount) {
List<SagaStep> steps = new ArrayList<>(); List<SagaStep> steps = new ArrayList<>();
List<SagaStep> rollbackSteps = new ArrayList<>(); List<SagaStep> rollbackSteps = new ArrayList<>();
@@ -197,33 +193,16 @@ public class BookingSagaHandler {
steps.add(step1); steps.add(step1);
rollbackSteps.add(0, step1); rollbackSteps.add(0, step1);
// 步骤2恢复会员卡权益 // 步骤2返还储值卡余额(含手续费逻辑)
SagaStep step2 = new SagaStep( SagaStep step2 = new SagaStep(
"恢复会员卡权益", "返还储值卡余额",
restoreCardUsageByCardType(memberId, recordId), refundStoredCardWithFee(memberId, storedValueAmount, cancelCount),
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId)) // 回滚时重新扣减
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId, storedValueAmount))
); );
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));
} }
@@ -253,73 +232,35 @@ public class BookingSagaHandler {
return bookingRepository.deleteById(bookingId); return bookingRepository.deleteById(bookingId);
} }
private Mono<Void> deductCardUsage(Long recordId, Integer deductTimes, Double deductAmount) { /**
if (deductTimes == 0 && deductAmount == 0.0) { * 从储值卡扣减余额
return Mono.empty(); */
} private Mono<Void> deductStoredCardBalance(Long memberId, BigDecimal storedValueAmount) {
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
return memberCardRecordService.findById(recordId) ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) return memberStoredCardService.consume(memberId, amount)
.flatMap(record -> { .flatMap(rows -> {
cn.novalon.gym.manage.member.enums.MemberCardRecordStatus status = record.getStatus(); if (rows == 0) {
if (status != cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE) { return Mono.error(new RuntimeException("储值卡扣减失败,余额不足"));
return Mono.error(new RuntimeException("会员卡状态无效,当前状态: " + (status != null ? status.getDesc() : "未知")));
} }
java.time.LocalDateTime expireTime = record.getExpireTime(); log.info("储值卡扣减成功: memberId={}, amount={}", memberId, amount);
if (expireTime != null && expireTime.isBefore(java.time.LocalDateTime.now())) { return Mono.empty();
return Mono.error(new RuntimeException("会员卡已过期"));
}
if (record.getRemainingTimes() != null && deductTimes > 0 && record.getRemainingTimes() < deductTimes) {
return Mono.error(new RuntimeException("会员卡剩余次数不足,当前剩余: " + record.getRemainingTimes() + ""));
}
if (record.getRemainingAmount() != null && deductAmount > 0 && record.getRemainingAmount() < deductAmount) {
return Mono.error(new RuntimeException("会员卡余额不足,当前剩余: " + record.getRemainingAmount()));
}
return memberCardRecordService.deductUsage(recordId, deductTimes, deductAmount)
.flatMap(rows -> {
if (rows == 0) {
return Mono.error(new RuntimeException("扣减会员卡权益失败,请重试"));
}
return Mono.empty();
});
}); });
} }
private Mono<Void> restoreCardUsage(Long recordId, Integer addTimes, Double addAmount) { /**
if (addTimes == 0 && addAmount == 0.0) { * 返还储值卡余额(含手续费逻辑,仅用于取消预约)
return Mono.empty(); */
} private Mono<Void> refundStoredCardWithFee(Long memberId, BigDecimal storedValueAmount, long cancelCount) {
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
return memberCardRecordService.findById(recordId) ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益"))) return memberStoredCardService.refundBalanceWithFee(memberId, amount, cancelCount)
.flatMap(record -> { .flatMap(refundedAmount -> {
// 使用当前记录的过期时间,避免清空过期时间 log.info("储值卡退款成功(含手续费): memberId={}, cancelCount={}, 退款金额={}", memberId, cancelCount, refundedAmount);
return memberCardRecordService.renewCard(recordId, addTimes, addAmount, record.getExpireTime()) return Mono.empty();
.flatMap(rows -> {
if (rows == 0) {
return Mono.error(new RuntimeException("恢复会员卡权益失败,请重试"));
}
return Mono.empty();
});
}); });
} }
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);
@@ -1,5 +1,6 @@
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;
@@ -29,6 +30,13 @@ 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"));
@@ -147,17 +155,17 @@ public class CourseLabelHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds"); List<?> rawIds = (List<?>) body.get("labelIds");
if (labelIdsInt == null || labelIdsInt.isEmpty()) { 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 = labelIdsInt.stream() List<Long> labelIds = rawIds.stream()
.map(Integer::longValue) .map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
return courseLabelService.addLabelsToType(typeId, labelIds) return courseLabelService.addLabelsToType(typeId, labelIds)
@@ -42,15 +42,11 @@ 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");
}
Long courseId = ((Number) body.get("courseId")).longValue(); Long courseId = toLong(body.get("courseId"), "courseId");
Long memberId = ((Number) body.get("memberId")).longValue(); Long memberId = toLong(body.get("memberId"), "memberId");
Long memberCardRecordId = ((Number) body.get("memberCardRecordId")).longValue();
return bookingService.bookCourse(courseId, memberId, memberCardRecordId) return bookingService.bookCourse(courseId, memberId)
.flatMap(booking -> { .flatMap(booking -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
@@ -76,7 +72,7 @@ public class GroupCourseBookingHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
Long memberId = ((Number) body.get("memberId")).longValue(); Long memberId = toLong(body.get("memberId"), "memberId");
return bookingService.cancelBooking(bookingId, memberId) return bookingService.cancelBooking(bookingId, memberId)
.flatMap(booking -> { .flatMap(booking -> {
@@ -129,6 +125,19 @@ public class GroupCourseBookingHandler {
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class); .body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
} }
/**
* 安全转换为 Long,支持 Number 和 String 类型
*/
private Long toLong(Object value, String fieldName) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
if (value instanceof String) {
return Long.valueOf((String) value);
}
throw new IllegalArgumentException("参数 " + fieldName + " 类型不正确");
}
/** /**
* 构建错误响应 * 构建错误响应
*/ */
@@ -7,21 +7,27 @@ 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 com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.slf4j.Logger;
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 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;
@@ -37,11 +43,12 @@ public class GroupCourseHandler {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@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 ServerResponse.ok() return groupCourseService.findAllAsVO(includeDeleted)
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class); .collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
} }
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表") @Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
@@ -65,7 +72,7 @@ public class GroupCourseHandler {
pageRequest.setOrder("asc"); pageRequest.setOrder("asc");
} }
return groupCourseService.findByPage(pageRequest, includeDeleted) return groupCourseService.findByPageAsVO(pageRequest, includeDeleted)
.flatMap(response -> ServerResponse.ok().bodyValue(response)); .flatMap(response -> ServerResponse.ok().bodyValue(response));
}); });
} }
@@ -289,4 +296,50 @@ public class GroupCourseHandler {
}); });
}); });
} }
@Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课")
public Mono<ServerResponse> checkCoachConflict(ServerRequest request) {
return request.bodyToMono(Map.class)
.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");
Object startTimeStr = body.get("startTime");
Object endTimeStr = body.get("endTime");
Object excludeCourseIdObj = body.get("excludeCourseId");
if (coachIdObj == null || startTimeStr == null || endTimeStr == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "coachId、startTime、endTime 不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
Long coachId = Long.valueOf(String.valueOf(coachIdObj));
LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr));
LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr));
Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null;
return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId)
.flatMap(conflicts -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("hasConflict", !conflicts.isEmpty());
response.put("conflicts", conflicts);
return ServerResponse.ok().bodyValue(response);
});
})
.onErrorResume(error -> {
logger.warn("检查教练时间冲突失败: {}", error.getMessage());
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "检查失败: " + error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
} }
@@ -1,5 +1,6 @@
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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -134,4 +135,12 @@ public class GroupCourseTypeHandler {
return ServerResponse.badRequest().bodyValue(response); 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,5 +1,6 @@
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;
@@ -10,15 +11,12 @@ 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为空的课程生成二维码并上传至阿里云OSS * 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并通过统一文件服务保存
*/ */
@Component @Component
public class QrCodeInitializer implements CommandLineRunner { public class QrCodeInitializer implements CommandLineRunner {
@@ -27,11 +25,14 @@ 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
@@ -58,20 +59,22 @@ public class QrCodeInitializer implements CommandLineRunner {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS // 生成二维码字节数组,通过统一文件服务保存
String uuid = UUID.randomUUID().toString().replace("-", ""); return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); .flatMap(qrCodeBytes -> {
String fileName = "qr_" + uuid + "_" + timestamp + ".png"; String fileName = "qrcode_" + course.getId() + ".png";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName); return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
})
course.setQrCodePath(ossUrl); .flatMap(sysFile -> {
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
// 更新数据库 course.setQrCodePath(qrCodeUrl);
return groupCourseRepository.update(course)
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}", return groupCourseRepository.update(course)
updated.getId(), updated.getCourseName(), ossUrl)) .doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, url={}",
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}", updated.getId(), updated.getCourseName(), qrCodeUrl))
course.getId(), course.getCourseName(), error.getMessage())); .doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
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);
@@ -1,5 +1,7 @@
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;
@@ -29,4 +31,6 @@ 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);
} }
@@ -89,4 +89,11 @@ public interface IGroupCourseBookingRepository {
* @return 更新记录数 * @return 更新记录数
*/ */
Mono<Integer> updateToAbsent(Long bookingId); Mono<Integer> updateToAbsent(Long bookingId);
/**
* 统计会员取消的预约次数
* @param memberId 会员ID
* @return 取消次数
*/
Mono<Long> countCancelledByMemberId(Long memberId);
} }
@@ -27,9 +27,14 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(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,5 +1,7 @@
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;
@@ -25,4 +27,6 @@ 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);
} }
@@ -1,5 +1,7 @@
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;
@@ -9,12 +11,17 @@ 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
@@ -26,12 +33,14 @@ 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) { GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.courseLabelDao = courseLabelDao; this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao; this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -158,4 +167,41 @@ 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);
});
}
} }
@@ -112,4 +112,9 @@ public class GroupCourseBookingRepository implements IGroupCourseBookingReposito
java.time.LocalDateTime now = java.time.LocalDateTime.now(); java.time.LocalDateTime now = java.time.LocalDateTime.now();
return groupCourseBookingDao.updateToAbsent(bookingId, now); return groupCourseBookingDao.updateToAbsent(bookingId, now);
} }
@Override
public Mono<Long> countCancelledByMemberId(Long memberId) {
return groupCourseBookingDao.countCancelledByMemberId(memberId);
}
} }
@@ -97,37 +97,19 @@ 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();
Sort sortObj = Sort.unsorted(); return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
if (sort != null && !sort.isEmpty()) { .flatMap(total -> {
sortObj = Sort.by(Sort.Direction.fromString(order), sort); if (total == 0) {
} 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)
int totalPages = (int) Math.ceil((double) total / size); .map(groupCourseConverter::toDomain)
return new PageResponse<>(courseList, totalPages, total, page, size); .collectList()
.map(courseList -> {
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(courseList, totalPages, total, page, size);
});
}); });
} }
@@ -137,7 +119,6 @@ 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);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -169,17 +150,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then(); .then();
} }
@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)
@@ -208,4 +178,28 @@ 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,5 +1,7 @@
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;
@@ -7,12 +9,18 @@ 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
@@ -22,10 +30,13 @@ 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
@@ -129,4 +140,46 @@ 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);
});
}
} }
@@ -1,46 +0,0 @@
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)
);
}
}
@@ -1,5 +1,7 @@
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;
@@ -27,4 +29,6 @@ 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,10 +17,9 @@ 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, Long memberCardRecordId); Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId);
/** /**
* 取消预约 * 取消预约
@@ -6,14 +6,20 @@ 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);
@@ -28,4 +34,14 @@ public interface IGroupCourseService {
Mono<Void> delete(Long id); Mono<Void> delete(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,5 +1,7 @@
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;
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
* @return 分类名称列表 * @return 分类名称列表
*/ */
Flux<String> findCategories(); Flux<String> findCategories();
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -1,5 +1,7 @@
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;
@@ -109,4 +111,12 @@ 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,7 +3,6 @@ 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;
@@ -25,7 +24,6 @@ import java.util.UUID;
* - 取消预约需在课程开始前至少2小时 * - 取消预约需在课程开始前至少2小时
* - 每节课最多20人 * - 每节课最多20人
* - 预约成功后发送提醒 * - 预约成功后发送提醒
* - 预约成功后扣减权益
* *
* 技术要点: * 技术要点:
* - 使用Redis缓存团课信息 * - 使用Redis缓存团课信息
@@ -44,7 +42,6 @@ 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 static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -54,18 +51,16 @@ 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) {
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;
} }
@Override @Override
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId) { public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId) {
logger.info("开始预约团课:courseId={}, memberId={}, memberCardRecordId={}", courseId, memberId, memberCardRecordId); logger.info("开始预约团课:courseId={}, memberId={}", courseId, memberId);
// 生成唯一请求ID用于分布式锁 // 生成唯一请求ID用于分布式锁
String requestId = UUID.randomUUID().toString(); String requestId = UUID.randomUUID().toString();
@@ -77,18 +72,26 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
return Mono.error(new RuntimeException("系统繁忙,请稍后重试")); return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
} }
// 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取 // 2. 从缓存或数据库获取课程信息,并用实时预约数覆盖currentMembers
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 status = course.getStatus(); Long courseStatus = course.getStatus();
if (status == null || status != 0L) { if (courseStatus == null || courseStatus != 0L) {
String errorMessage; String errorMessage;
if (status == null) { if (courseStatus == null) {
errorMessage = "课程状态异常"; errorMessage = "课程状态异常";
} else if (status == 1L) { } else if (courseStatus == 1L) {
errorMessage = "课程已取消,无法预约"; errorMessage = "课程已取消,无法预约";
} else if (status == 2L) { } else if (courseStatus == 2L) {
errorMessage = "课程已结束,无法预约"; errorMessage = "课程已结束,无法预约";
} else { } else {
errorMessage = "课程状态不可预约"; errorMessage = "课程状态不可预约";
@@ -131,56 +134,46 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
// 7. 验证是否已预约 // 7. 验证是否已预约
return bookingRepository.findValidBooking(courseId, memberId) return bookingRepository.findValidBooking(courseId, memberId)
.flatMap(existingBooking -> { .flatMap(existingBooking ->
return releaseLockAndError(courseId, requestId, "您已预约该课程"); releaseLockAndError(courseId, requestId, "您已预约该课程")
}) )
.switchIfEmpty( .switchIfEmpty(
// 8. 使用Redis原子操作验证课程人数是否已满 // 8. 创建预约记录
validateAndIncrementBookingCount(courseId, course.getMaxMembers()) Mono.defer(() -> {
.flatMap(countValid -> { GroupCourseBooking booking = new GroupCourseBooking();
if (countValid > course.getMaxMembers()) { booking.setCourseId(courseId);
return releaseLockAndError(courseId, requestId, "课程已满"); booking.setMemberId(memberId);
} booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0"); // 0-已预约
// 9. 创建预约记录 // 添加课程信息到预约记录
GroupCourseBooking booking = new GroupCourseBooking(); booking.setCourseName(course.getCourseName());
booking.setCourseId(courseId); booking.setCourseStartTime(course.getStartTime());
booking.setMemberId(memberId); booking.setCourseEndTime(course.getEndTime());
booking.setMemberCardRecordId(memberCardRecordId); booking.setLocation(course.getLocation());
booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0"); // 0-已预约
// 添加课程信息到预约记录 // 9. 保存预约记录
booking.setCourseName(course.getCourseName()); return bookingRepository.save(booking)
booking.setCourseStartTime(course.getStartTime()); .flatMap(saved -> {
booking.setCourseEndTime(course.getEndTime()); if (saved.getId() == null) {
booking.setLocation(course.getLocation()); return Mono.error(new RuntimeException("保存预约记录失败"));
}
// 10. 使用Saga事务执行预约(包含权益扣减) // 10. 释放锁
return bookingSagaHandler.executeBooking(booking, memberCardRecordId) return redisService.releaseLock(courseId, requestId)
.flatMap(savedBooking -> { .then(Mono.just(saved));
// 11. 释放锁 })
return redisService.releaseLock(courseId, requestId) .doOnSuccess(savedBooking -> {
.then(Mono.just(savedBooking)); logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
}) savedBooking.getId(), courseId, memberId);
.doOnSuccess(savedBooking -> { // 发布预约成功事件
logger.info("预约成功:bookingId={}, courseId={}, memberId={}", bookingReminderEventPublisher.publishBookingSuccessEvent(
savedBooking.getId(), courseId, memberId); savedBooking.getId(),
// 发布预约成功事件 savedBooking.getMemberId(),
bookingReminderEventPublisher.publishBookingSuccessEvent( savedBooking.getCourseName(),
savedBooking.getId(), savedBooking.getCourseStartTime().toString()
savedBooking.getMemberId(), );
savedBooking.getCourseName(), });
savedBooking.getCourseStartTime().toString() })
);
})
.doOnError(error -> {
// 回滚Redis计数
redisService.decrementBookingCount(courseId).subscribe();
logger.error("预约失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage());
});
})
); );
}); });
}) })
@@ -208,29 +201,6 @@ 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));
}
/** /**
* 释放锁并返回错误 * 释放锁并返回错误
*/ */
@@ -290,26 +260,29 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消"); "需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
} }
// 5. 使用Saga事务执行取消预约(包含权益恢复) // 5. 更新预约状态为已取消
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId) return bookingRepository.updateStatus(bookingId, "1")
.flatMap(updatedBooking -> { .flatMap(rows -> {
// 6. 释放锁 if (rows == 0) {
return redisService.releaseLock(bookingId, requestId) return Mono.error(new RuntimeException("更新预约状态失败"));
.then(Mono.just(updatedBooking)); }
}) // 6. 释放锁
.doOnSuccess(updatedBooking -> { return redisService.releaseLock(bookingId, requestId)
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); .then(bookingRepository.findById(bookingId));
// 发布预约取消事件 })
bookingReminderEventPublisher.publishBookingCancelEvent( .doOnSuccess(updatedBooking -> {
updatedBooking.getId(), logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
updatedBooking.getMemberId(), // 发布预约取消事件
updatedBooking.getCourseName() bookingReminderEventPublisher.publishBookingCancelEvent(
); updatedBooking.getId(),
}) updatedBooking.getMemberId(),
.doOnError(error -> { updatedBooking.getCourseName()
logger.error("取消预约失败:bookingId={}, memberId={}, error={}", );
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();
@@ -149,46 +149,4 @@ 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,11 +19,15 @@ 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.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;
@@ -34,7 +38,9 @@ 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.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@@ -51,6 +57,8 @@ 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:";
@@ -68,7 +76,9 @@ 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;
@@ -79,19 +89,22 @@ 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;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
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.just(detail); return Mono.<GroupCourseDetail>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());
@@ -123,6 +136,8 @@ 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,17 +183,64 @@ 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;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
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.just(groupCourse); return Mono.<GroupCourse>just(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());
@@ -200,7 +262,8 @@ public class GroupCourseService implements IGroupCourseService {
} }
}) })
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id)) .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
); )
.flatMap(this::enrichCurrentMembers);
} }
@Override @Override
@@ -217,6 +280,47 @@ public class GroupCourseService implements IGroupCourseService {
} }
} }
@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
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) { public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
@@ -224,17 +328,19 @@ 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; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
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.just(pageResponse); return Mono.<PageResponse<GroupCourse>>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());
@@ -267,6 +373,73 @@ 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)
@@ -287,15 +460,21 @@ public class GroupCourseService implements IGroupCourseService {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS // 生成二维码字节数组,通过统一文件服务保存
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent); return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
course.setQrCodePath(qrCodeUrl); .flatMap(qrCodeBytes -> {
String fileName = "qrcode_" + course.getId() + ".png";
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl); return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
})
// 更新团课信息,保存二维码路径 .flatMap(sysFile -> {
return groupCourseRepository.update(course) String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName())); course.setQrCodePath(qrCodeUrl);
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);
// 即使二维码生成失败,也返回成功创建的团课 // 即使二维码生成失败,也返回成功创建的团课
@@ -477,6 +656,15 @@ 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())) {
@@ -503,30 +691,13 @@ public class GroupCourseService implements IGroupCourseService {
return Mono.error(new RuntimeException("课程已满员,无法签到")); return Mono.error(new RuntimeException("课程已满员,无法签到"));
} }
// 校验5:用户今日是否已到店签到(直接查询sign_in_record表 // 校验5:用户已预约此课程(有效预约,状态为0-已预约
LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay(); return bookingRepository.findValidBooking(courseId, memberId)
LocalDateTime todayEnd = todayStart.plusDays(1); .switchIfEmpty(Mono.error(new RuntimeException("您未预约此团课")))
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") .flatMap(booking -> {
.bind("memberId", memberId) // 更新预约状态为已出席(2),不再手动计数
.bind("startTime", todayStart) return bookingRepository.updateStatus(booking.getId(), "2")
.bind("endTime", todayEnd) .thenReturn(course);
.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))
@@ -536,14 +707,15 @@ 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) // 检查课程状态是否为已取消(状态码1)或已结束(状态码2
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { Long status = course.getStatus();
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " + if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
(course.getStatus() != null ? course.getStatus() : "未知"))); return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
(status != null ? status : "未知")));
} }
// 删除课程 // 删除课程
@@ -569,6 +741,37 @@ public class GroupCourseService implements IGroupCourseService {
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(); .then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*"))
.then(redisUtil.deleteByPattern("datacount:statistics:*"))
.then();
}
@Override
public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) {
if (coachId == null || startTime == null || endTime == null) {
return Mono.just(Collections.emptyList());
}
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,6 +1,9 @@
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.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -9,18 +12,18 @@ 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.util.HashSet;
import java.util.Set;
@Service @Service
public class GroupCourseTypeService implements IGroupCourseTypeService { public class GroupCourseTypeService implements IGroupCourseTypeService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class); private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
private final IGroupCourseTypeRepository groupCourseTypeRepository; private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) { public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository; this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository;
} }
@Override @Override
@@ -71,9 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
return groupCourseTypeRepository.deleteById(id) // 检查是否有团课依赖该类型
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) return groupCourseRepository.findByCourseType(id)
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); .hasElements()
.flatMap(hasDependents -> {
if (hasDependents) {
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
}
return groupCourseTypeRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
});
} }
@Override @Override
@@ -83,4 +94,12 @@ 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()));
}
} }
@@ -1,109 +0,0 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 阿里云OSS工具类
*/
public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
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/";
/**
* 上传文件到阿里云OSS
*
* @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径)
* @return OSS访问地址
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
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);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} 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(自定义存储路径)
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径)
* @return OSS访问地址
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径
String ossFilePath = ossDirectory + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest);
// 构建访问地址
String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} 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();
}
}
}
}
@@ -10,46 +10,33 @@ 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 二维码内容
* @param savePath 保存路径 * @return PNG格式的二维码图片字节数组
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/ */
public static String generateQRCode(String content, String savePath, String fileName) { public static byte[] generateQrCodeBytes(String content) {
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);
@@ -58,99 +45,21 @@ 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);
String filePath = Paths.get(savePath, fileName + ".png").toString(); BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
Path path = Paths.get(filePath);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path); ByteArrayOutputStream baos = new ByteArrayOutputStream();
logger.info("二维码生成成功: {}", filePath); ImageIO.write(image, "PNG", baos);
byte[] bytes = baos.toByteArray();
baos.close();
return filePath; logger.info("二维码字节数组生成成功, size={} bytes", bytes.length);
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);
} }
} }
} }
@@ -0,0 +1,134 @@
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,9 +1,6 @@
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.*;
@@ -12,60 +9,41 @@ import static org.junit.jupiter.api.Assertions.*;
*/ */
class QRCodeUtilTest { class QRCodeUtilTest {
@TempDir
Path tempDir;
@Test @Test
void testGenerateQRCode() { void testGenerateQrCodeBytes() {
String content = "测试二维码内容"; String content = "测试二维码内容";
String qrCodePath = QRCodeUtil.generateQRCode(content); byte[] bytes = QRCodeUtil.generateQrCodeBytes(content);
assertNotNull(qrCodePath, "二维码路径不应为空"); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式"); assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
System.out.println("生成的二维码路径: " + qrCodePath); System.out.println("生成的二维码字节大小: " + bytes.length + " bytes");
} }
@Test @Test
void testGenerateQRCodeWithCustomPath() { void testGenerateQrCodeBytesWithJsonContent() {
String content = "自定义路径测试"; String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-07-14T10:00:00\"}";
String customPath = tempDir.toString();
String fileName = "test_qrcode";
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName); byte[] bytes = QRCodeUtil.generateQrCodeBytes(jsonContent);
assertNotNull(qrCodePath, "二维码路径不应为空"); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式"); assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
System.out.println("生成的二维码路径: " + qrCodePath); System.out.println("JSON内容二维码字节大小: " + bytes.length + " bytes");
} }
@Test @Test
void testGenerateQRCodeWithJsonContent() { void testGenerateQrCodeBytesWithLongContent() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}"; StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append("这是第").append(i).append("行测试数据\n");
}
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent); byte[] bytes = QRCodeUtil.generateQrCodeBytes(sb.toString());
assertNotNull(qrCodePath, "二维码路径不应为空"); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式"); assertTrue(bytes.length > 0, "长内容二维码应正常生成");
System.out.println("JSON内容二维码路径: " + qrCodePath); System.out.println("内容二维码字节大小: " + bytes.length + " bytes");
} }
}
@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("https://"), "OSS访问地址应为HTTPS");
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl);
}
}
@@ -16,6 +16,9 @@ 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();
@@ -63,13 +63,24 @@ public class MemberCardHandler {
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(MemberCard.class) return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(card -> { .switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
card.setMemberCardId(id); .zipWith(request.bodyToMono(MemberCard.class))
return memberCardService.save(card); .flatMap(tuple -> {
MemberCard existing = tuple.getT1();
MemberCard body = tuple.getT2();
// 仅更新非 null 字段,避免 null 覆盖已有值
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
return memberCardService.save(existing);
}) })
.flatMap(updated -> ServerResponse.ok().bodyValue(updated)) .flatMap(updated -> ServerResponse.ok().bodyValue(updated));
.switchIfEmpty(ServerResponse.notFound().build());
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
@@ -155,8 +155,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.error("手机号解密失败, memberId: {}", detail.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
detail.setPhone(null); detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
} }
} }
return ServerResponse.ok() return ServerResponse.ok()
@@ -204,8 +204,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.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -222,11 +222,13 @@ 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: {}", adminId, pageNum, pageSize); log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
// TODO: 补充签到记录 adminId, pageNum, pageSize, sortField, sortOrder);
return memberService.findAll(pageNum, pageSize) return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
.map(member -> { .map(member -> {
// 解密手机号 // 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) { if (member.getPhone() != null && !member.getPhone().isEmpty()) {
@@ -234,8 +236,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.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -110,6 +110,18 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500") @Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
Flux<MemberCardRecord> findExpiredCards(); Flux<MemberCardRecord> findExpiredCards();
/**
* 根据支付订单ID查询会员卡记录(用于幂等性检查)
*/
@Query("SELECT * FROM member_card_record WHERE source_order_id = :sourceOrderId AND deleted_at IS NULL")
Mono<MemberCardRecord> findBySourceOrderId(Long sourceOrderId);
/**
* 查询会员在指定时间后购买的同类活跃卡(防前端重复调用)
*/
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND member_card_id = :memberCardId AND status = 'ACTIVE' AND deleted_at IS NULL AND purchase_time > :since ORDER BY purchase_time DESC LIMIT 1")
Mono<MemberCardRecord> findRecentActivePurchase(Long memberId, Long memberCardId, LocalDateTime since);
/** /**
* 查询所有有效记录 * 查询所有有效记录
*/ */
@@ -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 created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") "ORDER BY id ASC 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);
@@ -34,6 +34,14 @@ public interface MemberStoredCardRepository extends R2dbcRepository<MemberStored
"WHERE member_id = :memberId AND deleted_at IS NULL") "WHERE member_id = :memberId AND deleted_at IS NULL")
Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount); Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount);
/**
* 储值卡返还 - 增加余额(用于取消预约退款)
*/
@Modifying
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, updated_at = NOW() " +
"WHERE member_id = :memberId AND deleted_at IS NULL")
Mono<Integer> refundBalance(Long memberId, BigDecimal addAmount);
/** /**
* 储值卡消费 - 扣减余额 * 储值卡消费 - 扣减余额
*/ */
@@ -52,6 +52,17 @@ public interface IMemberStoredCardService {
*/ */
Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount); Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount);
/**
* 储值卡返还(取消预约退款,仅增加余额不修改充值总额)
*/
Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount);
/**
* 储值卡返还(内部调用,无需密码,含手续费逻辑)
* @return 实际返还金额
*/
Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount);
/** /**
* 查询会员的充值记录列表 * 查询会员的充值记录列表
*/ */
@@ -58,7 +58,7 @@ public interface MemberService {
* @param pageSize 页大小 * @param pageSize 页大小
* @return 所有会员信息 * @return 所有会员信息
*/ */
Flux<Member> findAll(Integer pageNum, Integer pageSize); Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
/** /**
* 前台管理端获取会员详情(含会员卡信息) * 前台管理端获取会员详情(含会员卡信息)
@@ -213,9 +213,9 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
.filter(record -> record.getMemberCardType() == null || .filter(record -> record.getMemberCardType() == null ||
!record.getMemberCardType().equals("STORED_VALUE_CARD")) !record.getMemberCardType().equals("STORED_VALUE_CARD"))
.collectList() .collectList()
.map(records -> { .flatMap(records -> {
if (records.isEmpty()) { if (records.isEmpty()) {
return null; return Mono.empty();
} }
// 优先找临期卡(剩余1-3天) // 优先找临期卡(剩余1-3天)
@@ -230,7 +230,7 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
.orElse(null); .orElse(null);
if (expiringCard != null) { if (expiringCard != null) {
return expiringCard; return Mono.just(expiringCard);
} }
// 再找普通有效卡(剩余>3天) // 再找普通有效卡(剩余>3天)
@@ -245,11 +245,11 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
.orElse(null); .orElse(null);
if (activeCard != null) { if (activeCard != null) {
return activeCard; return Mono.just(activeCard);
} }
// 最后返回第一张卡 // 最后返回第一张卡
return records.get(0); return Mono.just(records.get(0));
}); });
} }
@@ -44,6 +44,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:"; private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
private static final long CACHE_EXPIRE_SECONDS = 300; private static final long CACHE_EXPIRE_SECONDS = 300;
private static final long DUPLICATE_WINDOW_SECONDS = 10;
public MemberCardServiceImpl(MemberCardRepository memberCardRepository, public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
MemberCardRecordRepository recordRepository, MemberCardRecordRepository recordRepository,
@@ -130,6 +131,30 @@ public class MemberCardServiceImpl implements IMemberCardService {
if (memberCardId == null) { if (memberCardId == null) {
return Mono.error(new RuntimeException("会员卡类型ID不能为空")); return Mono.error(new RuntimeException("会员卡类型ID不能为空"));
} }
// 快速路径:幂等性检查,同一笔支付订单已创建过记录则直接返回
if (sourceOrderId != null) {
return recordRepository.findBySourceOrderId(sourceOrderId)
.flatMap(existingRecord -> {
log.info("支付订单已生成会员卡记录,幂等返回: sourceOrderId={}, memberCardRecordId={}",
sourceOrderId, existingRecord.getMemberCardRecordId());
return Mono.just(existingRecord);
})
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
}
// sourceOrderId为null(前端直接调用):检查10秒内是否已有同类卡,防前端重复调用
LocalDateTime since = LocalDateTime.now().minusSeconds(DUPLICATE_WINDOW_SECONDS);
return recordRepository.findRecentActivePurchase(memberId, memberCardId, since)
.flatMap(existingRecord -> {
log.info("前端重复购买检测命中: memberId={}, memberCardId={}, memberCardRecordId={}, purchaseTime={}",
memberId, memberCardId, existingRecord.getMemberCardRecordId(), existingRecord.getPurchaseTime());
return Mono.just(existingRecord);
})
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
}
private Mono<MemberCardRecord> doPurchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
// 前端传的是数据库主键id,不是业务字段member_card_id // 前端传的是数据库主键id,不是业务字段member_card_id
return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId) return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
@@ -143,8 +168,19 @@ public class MemberCardServiceImpl implements IMemberCardService {
return distributedLockService.executeWithLock( return distributedLockService.executeWithLock(
memberId.toString(), memberId.toString(),
cardType.name(), cardType.name(),
Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)) Mono.defer(() -> {
// 修改:将 card 对象传递给 createTransaction 以获取购买金额 // 持锁后再次检查幂等,防止并发请求同时通过外层检查
if (sourceOrderId != null) {
return recordRepository.findBySourceOrderId(sourceOrderId)
.flatMap(existingRecord -> {
log.info("持锁幂等检查命中: sourceOrderId={}, memberCardRecordId={}",
sourceOrderId, existingRecord.getMemberCardRecordId());
return Mono.just(existingRecord);
})
.switchIfEmpty(Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)));
}
return createCardRecord(memberId, memberCardId, sourceOrderId, card);
})
.flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡") .flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡")
.thenReturn(record)) .thenReturn(record))
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record) .flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
@@ -26,6 +26,7 @@ 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;
@@ -157,6 +158,7 @@ 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)
@@ -165,6 +167,7 @@ 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();
} }
@@ -230,13 +233,31 @@ 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) { public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
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);
@@ -249,50 +270,51 @@ 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)
.flatMap(cached -> { .filter(cached -> cached != null)
if (cached != null) { .switchIfEmpty(Mono.defer(() ->
log.debug("从缓存获取会员详情, memberId: {}", memberId); memberRepository.findById(memberId)
return Mono.just(cached); .switchIfEmpty(Mono.error(() -> {
} log.error("会员不存在: memberId={}", memberId);
return memberRepository.findById(memberId) throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
.zipWith( }))
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) .zipWith(
.collectList(), memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
(baseInfo, cardList) -> { .collectList(),
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); (baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
memberDetailVO.setGenderDesc(genderEnum.getDesc()); memberDetailVO.setGenderDesc(genderEnum.getDesc());
List<MemberCardInfoVO> enrichedCards = cardList.stream() List<MemberCardInfoVO> enrichedCards = cardList.stream()
.peek(vo -> { .peek(vo -> {
if (vo.getMemberCardType() != null) { if (vo.getMemberCardType() != null) {
try { try {
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc()); vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType()); vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
} }
} if (vo.getMemberCardStatus() != null) {
if (vo.getMemberCardStatus() != null) { vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); }
} })
}) .collect(Collectors.toList());
.collect(Collectors.toList()); memberDetailVO.setMemberCards(enrichedCards);
memberDetailVO.setMemberCards(enrichedCards);
long activeCount = enrichedCards.stream() long activeCount = enrichedCards.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
.count(); .count();
memberDetailVO.setActiveCardCount((int) activeCount); memberDetailVO.setActiveCardCount((int) activeCount);
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
return memberDetailVO; return memberDetailVO;
} }
) )
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo))); .then(Mono.just(vo)))
}); ));
} }
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository; import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository;
import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository; import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository;
import cn.novalon.gym.manage.member.service.IMemberStoredCardService; import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
import cn.novalon.gym.manage.sys.core.service.ISysConfigService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,12 +23,15 @@ public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
private final MemberStoredCardRepository memberStoredCardRepository; private final MemberStoredCardRepository memberStoredCardRepository;
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository; private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
private final ISysConfigService sysConfigService;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository, public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository,
MemberStoredCardRechargeRepository memberStoredCardRechargeRepository) { MemberStoredCardRechargeRepository memberStoredCardRechargeRepository,
ISysConfigService sysConfigService) {
this.memberStoredCardRepository = memberStoredCardRepository; this.memberStoredCardRepository = memberStoredCardRepository;
this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository; this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository;
this.sysConfigService = sysConfigService;
this.passwordEncoder = new BCryptPasswordEncoder(); this.passwordEncoder = new BCryptPasswordEncoder();
} }
@@ -137,12 +141,94 @@ public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
})); }));
} }
@Override
public Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount) {
// 从系统配置读取免手续费次数阈值,默认3次
Mono<Long> freeLimitMono = sysConfigService.getConfigValue("group_course.cancel_free_limit")
.map(Long::parseLong)
.defaultIfEmpty(3L);
return freeLimitMono.flatMap(freeLimit ->
memberStoredCardRepository.findByMemberId(memberId)
.flatMap(card -> {
// 验证密码
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
log.warn("[储值卡退款] 未设置支付密码: memberId={}", memberId);
return Mono.just(PayResult.fail("请先设置支付密码"));
}
if (!passwordEncoder.matches(password, card.getPayPassword())) {
log.warn("[储值卡退款] 密码错误: memberId={}", memberId);
return Mono.just(PayResult.fail("支付密码错误"));
}
// 前N次取消不扣手续费,超出后扣除10%
BigDecimal refundAmount = amount;
BigDecimal fee = BigDecimal.ZERO;
if (cancelCount >= freeLimit) {
fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
refundAmount = amount.subtract(fee);
log.info("[储值卡退款] 第{}次取消,已超出免手续费阈值({}次),扣除10%手续费: 原额={}, 手续费={}, 退款={}",
cancelCount + 1, freeLimit, amount, fee, refundAmount);
} else {
log.info("[储值卡退款] 第{}次取消,在免手续费阈值({}次)内,全额退款: 原额={}",
cancelCount + 1, freeLimit, amount);
}
// 执行退款
BigDecimal finalRefundAmount = refundAmount;
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
.flatMap(rows -> {
if (rows > 0) {
return memberStoredCardRepository.findByMemberId(memberId)
.map(updatedCard -> PayResult.success(
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
} else {
log.warn("[储值卡退款] 退款失败: memberId={}, amount={}", memberId, finalRefundAmount);
return Mono.just(PayResult.fail("退款失败,请稍后重试"));
}
});
})
.switchIfEmpty(Mono.defer(() -> {
log.warn("[储值卡退款] 储值卡不存在: memberId={}", memberId);
return Mono.just(PayResult.fail("储值卡不存在"));
}))
);
}
@Override @Override
public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) { public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) {
int offset = (page - 1) * size; int offset = (page - 1) * size;
return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset); return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset);
} }
@Override
public Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount) {
return sysConfigService.getConfigValue("group_course.cancel_free_limit")
.map(Long::parseLong)
.defaultIfEmpty(3L)
.flatMap(freeLimit -> {
BigDecimal refundAmount;
if (cancelCount >= freeLimit) {
BigDecimal fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
refundAmount = amount.subtract(fee);
log.info("[储值卡退款-内部] 第{}次取消,超出免手续费阈值({}次),扣除10%: 原额={}, 手续费={}, 退款={}",
cancelCount + 1, freeLimit, amount, fee, refundAmount);
} else {
refundAmount = amount;
log.info("[储值卡退款-内部] 第{}次取消,在免手续费阈值({}次)内,全额退款: {}",
cancelCount + 1, freeLimit, amount);
}
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
.flatMap(rows -> {
if (rows > 0) {
return Mono.just(refundAmount);
}
return Mono.error(new RuntimeException("储值卡退款失败"));
});
});
}
@Override @Override
public Mono<Long> countRechargeRecords(Long memberId) { public Mono<Long> countRechargeRecords(Long memberId) {
return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId); return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId);
@@ -43,12 +43,25 @@ 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"); log.info("微信jsCode2Session API, code: {}", code);
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
@@ -152,6 +165,12 @@ 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,6 +7,7 @@ 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;
/** /**
@@ -25,6 +26,9 @@ public class MemberInfoVO {
// 会员 ID // 会员 ID
private Long id; private Long id;
// 会员编号
private String memberNo;
// 昵称 // 昵称
private String nickname; private String nickname;
@@ -48,4 +52,7 @@ public class MemberInfoVO {
// 是否已关注公众号 // 是否已关注公众号
private Boolean isSubscribed; private Boolean isSubscribed;
// 最后登录时间
private LocalDateTime lastLoginAt;
} }
@@ -222,28 +222,26 @@ public class PaymentServiceImpl implements PaymentService {
if ("00000000".equals(respCode)) { if ("00000000".equals(respCode)) {
if ("S".equals(transStat)) { if ("S".equals(transStat)) {
if (!"SUCCESS".equals(order.getPayStatus())) { if (!"SUCCESS".equals(order.getPayStatus())) {
log.info("[Huifu] 查询到支付成功,更新订单状态并触发业务处理, orderNo={}, orderType={}", log.info("[Huifu] 查询到支付成功,更新订单状态并触发业务通知, orderNo={}", order.getOrderNo());
order.getOrderNo(), order.getOrderType());
order.setPayStatus("SUCCESS"); order.setPayStatus("SUCCESS");
order.setHfSeqId(String.valueOf(response.getOrDefault("hf_seq_id", order.getHfSeqId()))); order.setHfSeqId(String.valueOf(response.getOrDefault("hf_seq_id", order.getHfSeqId())));
order.setPayTime(LocalDateTime.now()); order.setPayTime(LocalDateTime.now());
return paymentOrderRepository.save(order) return paymentOrderRepository.save(order)
.flatMap(savedOrder -> { .flatMap(savedOrder -> {
log.info("[Huifu] 订单状态已更新,开始触发业务处理, orderNo={}", savedOrder.getOrderNo()); log.info("[Huifu] 订单状态已更新为SUCCESS, orderNo={}", savedOrder.getOrderNo());
return paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "SUCCESS", memberId) return paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "SUCCESS", savedOrder.getMemberId())
.thenReturn(convertToResponse(savedOrder)); .thenReturn(convertToResponse(savedOrder));
}); });
} else { } else {
log.info("[Huifu] 订单已处理过,跳过业务处理, orderNo={}", order.getOrderNo()); log.info("[Huifu] 订单已处理过,跳过, orderNo={}", order.getOrderNo());
} }
} else if ("F".equals(transStat)) { } else if ("F".equals(transStat)) {
if (!"FAIL".equals(order.getPayStatus())) { if (!"FAIL".equals(order.getPayStatus())) {
log.info("[Huifu] 查询到支付失败,更新订单状态, orderNo={}", order.getOrderNo()); log.info("[Huifu] 查询到支付失败,更新订单状态, orderNo={}", order.getOrderNo());
order.setPayStatus("FAIL"); order.setPayStatus("FAIL");
order.setErrorMsg(String.valueOf(response.getOrDefault("resp_desc", "支付失败"))); order.setErrorMsg(String.valueOf(response.getOrDefault("resp_desc", "支付失败")));
return paymentOrderRepository.save(order) return paymentOrderRepository.save(order)
.flatMap(savedOrder -> paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "FAIL", memberId) .map(savedOrder -> convertToResponse(savedOrder));
.thenReturn(convertToResponse(savedOrder)));
} }
} else if ("P".equals(transStat)) { } else if ("P".equals(transStat)) {
order.setPayStatus("PENDING"); order.setPayStatus("PENDING");
+2915 -942
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -169,6 +169,12 @@
<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,7 +25,8 @@ 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 {
@@ -16,9 +16,12 @@ 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.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;
@@ -63,6 +66,7 @@ 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,
@@ -82,7 +86,9 @@ public class SystemRouter {
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler, DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler, PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler) { PaymentHandler paymentHandler,
CoachHandler coachHandler,
CoachCourseHandler coachCourseHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由 ==========
@@ -116,6 +122,17 @@ 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)
@@ -188,6 +205,14 @@ 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)
@@ -257,8 +282,11 @@ public class SystemRouter {
// ===== 会员卡类型管理 ===== // ===== 会员卡类型管理 =====
.GET("/api/member-cards/active", memberCardHandler::getActiveCards) .GET("/api/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/member-cards", memberCardHandler::listMemberCards)
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById) .GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/member-cards", memberCardHandler::createMemberCard) .POST("/api/member-cards", memberCardHandler::createMemberCard)
.PUT("/api/member-cards/{id}", memberCardHandler::updateMemberCard)
.DELETE("/api/member-cards/{id}", memberCardHandler::deleteMemberCard)
// ===== 会员卡记录管理(核心业务)===== // ===== 会员卡记录管理(核心业务)=====
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard) .POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
@@ -305,6 +333,7 @@ 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)
@@ -313,6 +342,7 @@ 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)
@@ -347,6 +377,7 @@ public class SystemRouter {
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.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)
// ========= 签到模块路由 ========== // ========= 签到模块路由 ==========
// ===== 签到核心功能 ===== // ===== 签到核心功能 =====
@@ -22,7 +22,6 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
private final MemberStoredCardRepository memberStoredCardRepository; private final MemberStoredCardRepository memberStoredCardRepository;
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository; private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
private final IMemberCardService memberCardService; private final IMemberCardService memberCardService;
private Long memberIdGlob;
public PaymentNotifyServiceImpl(PaymentOrderRepository paymentOrderRepository, public PaymentNotifyServiceImpl(PaymentOrderRepository paymentOrderRepository,
MemberStoredCardRepository memberStoredCardRepository, MemberStoredCardRepository memberStoredCardRepository,
@@ -35,8 +34,7 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
} }
@Override @Override
public Mono<Void> notifyPaymentStatus(String orderNo, String status,Long memberId) { public Mono<Void> notifyPaymentStatus(String orderNo, String status, Long memberId) {
memberIdGlob = memberId;
return paymentOrderRepository.findByOrderNo(orderNo) return paymentOrderRepository.findByOrderNo(orderNo)
.flatMap(order -> { .flatMap(order -> {
log.info("[PaymentNotify] 支付通知: orderNo={}, status={}, orderType={}", orderNo, status, order.getOrderType()); log.info("[PaymentNotify] 支付通知: orderNo={}, status={}, orderType={}", orderNo, status, order.getOrderType());
@@ -114,19 +112,33 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
final Long finalMemberCardId = memberCardId; final Long finalMemberCardId = memberCardId;
final int finalCount = count; final int finalCount = count;
final Long sourceOrderId = order.getId(); final Long sourceOrderId = order.getId();
final Long orderMemberId = order.getMemberId();
log.info("[PaymentNotify] 处理会员卡购买: orderNo={}, memberId={}, memberCardId={}, count={}", log.info("[PaymentNotify] 处理会员卡购买: orderNo={}, memberId={}, memberCardId={}, count={}",
order.getOrderNo(), memberIdGlob, finalMemberCardId, finalCount); order.getOrderNo(), orderMemberId, finalMemberCardId, finalCount);
return memberCardService.purchaseCard(memberIdGlob, finalMemberCardId, sourceOrderId) // 幂等性检查:同一笔支付订单只创建一次会员卡记录
return memberCardService.purchaseCard(orderMemberId, finalMemberCardId, sourceOrderId)
.doOnSuccess(record -> log.info("[PaymentNotify] 会员卡购买完成: orderNo={}, memberId={}, memberCardRecordId={}", .doOnSuccess(record -> log.info("[PaymentNotify] 会员卡购买完成: orderNo={}, memberId={}, memberCardRecordId={}",
order.getOrderNo(), memberIdGlob, record.getMemberCardRecordId())) order.getOrderNo(), orderMemberId, record.getMemberCardRecordId()))
.doOnError(e -> log.error("[PaymentNotify] 会员卡购买异常, orderNo={}", order.getOrderNo(), e)) .doOnError(e -> log.error("[PaymentNotify] 会员卡购买异常, orderNo={}", order.getOrderNo(), e))
.onErrorResume(e -> Mono.empty()) .onErrorResume(e -> Mono.empty())
.then(); .then();
} }
private Mono<Void> handleStoredCardRecharge(PaymentOrder order) { private Mono<Void> handleStoredCardRecharge(PaymentOrder order) {
String orderNo = order.getOrderNo();
return memberStoredCardRechargeRepository.findByOrderNo(orderNo)
.flatMap(existing -> {
log.info("[PaymentNotify] 充值记录已存在,跳过重复处理, orderNo={}, status={}", orderNo, existing.getStatus());
return Mono.<Void>empty();
})
.switchIfEmpty(Mono.defer(() -> doHandleStoredCardRecharge(order)))
.then();
}
private Mono<Void> doHandleStoredCardRecharge(PaymentOrder order) {
String remark = order.getRemark(); String remark = order.getRemark();
log.info("[PaymentNotify] 开始处理储值卡充值, orderNo={}, memberId={}, orderType={}, remark={}", log.info("[PaymentNotify] 开始处理储值卡充值, orderNo={}, memberId={}, orderType={}, remark={}",
order.getOrderNo(), order.getMemberId(), order.getOrderType(), remark); order.getOrderNo(), order.getMemberId(), order.getOrderType(), remark);
@@ -166,7 +178,7 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
final BigDecimal finalRechargeAmount = rechargeAmount; final BigDecimal finalRechargeAmount = rechargeAmount;
final BigDecimal finalBonus = bonus != null ? bonus : BigDecimal.ZERO; final BigDecimal finalBonus = bonus != null ? bonus : BigDecimal.ZERO;
final BigDecimal totalAddAmount = finalRechargeAmount.add(finalBonus); final BigDecimal totalAddAmount = finalRechargeAmount.add(finalBonus);
Long memberId = memberIdGlob; Long memberId = order.getMemberId();
final String orderNo = order.getOrderNo(); final String orderNo = order.getOrderNo();
final BigDecimal payAmount = order.getTransAmt(); final BigDecimal payAmount = order.getTransAmt();
final LocalDateTime now = LocalDateTime.now(); final LocalDateTime now = LocalDateTime.now();
@@ -2,9 +2,9 @@ spring:
cache: cache:
type: none type: none
r2dbc: r2dbc:
url: r2dbc:postgresql://localhost:5432/manage_system url: r2dbc:postgresql://localhost:55432/manage_system
username: postgres username: novalon
password: 123456 password: novalon123
pool: pool:
initial-size: 5 initial-size: 5
max-size: 20 max-size: 20
@@ -12,10 +12,10 @@ spring:
max-life-time: 30m max-life-time: 30m
acquire-timeout: 3s acquire-timeout: 3s
flyway: flyway:
url: jdbc:postgresql://localhost:5432/manage_system url: jdbc:postgresql://localhost:55432/manage_system
user: postgres user: novalon
password: 123456 password: novalon123
enabled: false enabled: true
locations: classpath:db/migration locations: classpath:db/migration
baseline-on-migrate: true baseline-on-migrate: true
validate-on-migrate: false validate-on-migrate: false
@@ -15,9 +15,9 @@ spring:
exclude: exclude:
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration - org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
r2dbc: r2dbc:
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system} url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:postgres} username: ${DB_USERNAME:novalon}
password: ${DB_PASSWORD:123456} password: ${DB_PASSWORD:novalon123}
pool: pool:
initial-size: 10 initial-size: 10
max-size: 50 max-size: 50
@@ -25,12 +25,12 @@ spring:
max-life-time: 1h max-life-time: 1h
acquire-timeout: 5s acquire-timeout: 5s
datasource: datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system} url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
username: ${DB_USERNAME:postgres} username: ${DB_USERNAME:novalon}
password: ${DB_PASSWORD:123456} password: ${DB_PASSWORD:novalon123}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
flyway: flyway:
enabled: false enabled: true
locations: classpath:db/migration locations: classpath:db/migration
baseline-on-migrate: true baseline-on-migrate: true
baseline-version: 0 baseline-version: 0
@@ -1,18 +1,18 @@
# 微信配置(测试环境使用模拟数据) # 微信配置(测试环境使用模拟数据)
wechat: wechat:
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境) # Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
mock-enabled: false mock-enabled: true
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:
@@ -1,5 +1,9 @@
package cn.novalon.gym.manage.common.config; package cn.novalon.gym.manage.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
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;
@@ -8,6 +12,9 @@ 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 配置类(响应式版本)
* *
@@ -17,20 +24,41 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@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) {
GenericJackson2JsonRedisSerializer jsonSerializer =
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(new GenericJackson2JsonRedisSerializer()) .value(jsonSerializer)
.hashKey(StringRedisSerializer.UTF_8) .hashKey(StringRedisSerializer.UTF_8)
.hashValue(new GenericJackson2JsonRedisSerializer()) .hashValue(jsonSerializer)
.build(); .build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
@@ -12,6 +12,8 @@ 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;
@@ -52,4 +54,20 @@ 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,5 +1,6 @@
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;
@@ -13,6 +14,7 @@ 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 {
@@ -34,12 +36,19 @@ 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)
.map(obj -> clazz.isInstance(obj) ? (T) obj : null); .filter(clazz::isInstance)
.map(clazz::cast)
.onErrorResume(e -> {
// 旧缓存数据格式不兼容时静默跳过,后续逻辑会 fallback 到 DB 查询
log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage());
return Mono.empty();
});
} }
/** /**
@@ -0,0 +1,73 @@
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());
}
}
@@ -0,0 +1,22 @@
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);
}
@@ -0,0 +1,127 @@
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;
}
}
@@ -0,0 +1,59 @@
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);
}
}
@@ -0,0 +1,38 @@
-- ============================================
-- 团课推荐表
-- 版本: 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 '删除时间(软删除)';
@@ -0,0 +1,9 @@
-- ============================================
-- 为团课表添加二维码路径字段
-- 版本: V11
-- 描述: 添加团课二维码路径,用于扫码签到
-- ============================================
ALTER TABLE group_course ADD COLUMN IF NOT EXISTS qr_code_path VARCHAR(500);
COMMENT ON COLUMN group_course.qr_code_path IS '二维码图片路径';
@@ -0,0 +1,10 @@
-- ============================================
-- 为 member_user 表添加 deleted_at 字段
-- 版本: V12
-- 描述: 补充软删除时间字段,与BaseEntity映射对齐
-- ============================================
ALTER TABLE member_user
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
COMMENT ON COLUMN member_user.deleted_at IS '软删除时间';
@@ -0,0 +1,10 @@
-- ============================================
-- 为 member_card_transactions 表添加 updated_at 字段
-- 版本: V13
-- 描述: 补充更新时间字段,与BaseEntity映射对齐
-- ============================================
ALTER TABLE member_card_transactions
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
COMMENT ON COLUMN member_card_transactions.updated_at IS '更新时间';
@@ -0,0 +1,63 @@
-- ============================================
-- 支付订单表
-- 版本: V14
-- 描述: 创建支付订单表,用于记录支付交易信息
-- ============================================
CREATE TABLE IF NOT EXISTS payment_order (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(64) NOT NULL UNIQUE,
member_id BIGINT,
order_type VARCHAR(32),
goods_desc VARCHAR(256),
trans_amt DECIMAL(12, 2),
trade_type VARCHAR(32),
pay_status VARCHAR(16) DEFAULT 'PENDING',
huifu_id VARCHAR(64),
req_seq_id VARCHAR(64),
req_date VARCHAR(16),
hf_seq_id VARCHAR(64),
pay_info TEXT,
qr_code TEXT,
pay_url TEXT,
remark VARCHAR(512),
notify_url VARCHAR(512),
pay_time TIMESTAMP,
expire_time TIMESTAMP,
error_code VARCHAR(64),
error_msg VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_payment_order_order_no ON payment_order(order_no);
CREATE INDEX IF NOT EXISTS idx_payment_order_member_id ON payment_order(member_id);
CREATE INDEX IF NOT EXISTS idx_payment_order_req_seq_id ON payment_order(req_seq_id);
CREATE INDEX IF NOT EXISTS idx_payment_order_status ON payment_order(pay_status);
CREATE INDEX IF NOT EXISTS idx_payment_order_expire_time ON payment_order(expire_time);
COMMENT ON TABLE payment_order IS '支付订单表';
COMMENT ON COLUMN payment_order.order_no IS '订单号';
COMMENT ON COLUMN payment_order.member_id IS '会员ID';
COMMENT ON COLUMN payment_order.order_type IS '订单类型';
COMMENT ON COLUMN payment_order.goods_desc IS '商品描述';
COMMENT ON COLUMN payment_order.trans_amt IS '交易金额';
COMMENT ON COLUMN payment_order.trade_type IS '交易类型';
COMMENT ON COLUMN payment_order.pay_status IS '支付状态:PENDING-待支付,SUCCESS-成功,FAILED-失败';
COMMENT ON COLUMN payment_order.huifu_id IS '汇付商户ID';
COMMENT ON COLUMN payment_order.req_seq_id IS '请求流水号';
COMMENT ON COLUMN payment_order.req_date IS '请求日期';
COMMENT ON COLUMN payment_order.hf_seq_id IS '汇付流水号';
COMMENT ON COLUMN payment_order.pay_info IS '支付信息';
COMMENT ON COLUMN payment_order.qr_code IS '二维码';
COMMENT ON COLUMN payment_order.pay_url IS '支付链接';
COMMENT ON COLUMN payment_order.remark IS '备注';
COMMENT ON COLUMN payment_order.notify_url IS '通知地址';
COMMENT ON COLUMN payment_order.pay_time IS '支付时间';
COMMENT ON COLUMN payment_order.expire_time IS '过期时间';
COMMENT ON COLUMN payment_order.error_code IS '错误码';
COMMENT ON COLUMN payment_order.error_msg IS '错误信息';
COMMENT ON COLUMN payment_order.created_at IS '创建时间';
COMMENT ON COLUMN payment_order.updated_at IS '更新时间';
COMMENT ON COLUMN payment_order.deleted_at IS '删除时间';
@@ -0,0 +1,9 @@
-- ============================================
-- 支付订单表添加 party_order_id 字段
-- 版本: V15
-- 描述: 添加汇付支付返回的商户订单号
-- ============================================
ALTER TABLE payment_order ADD COLUMN IF NOT EXISTS party_order_id VARCHAR(64);
COMMENT ON COLUMN payment_order.party_order_id IS '汇付商户订单号';
@@ -0,0 +1,58 @@
-- ============================================
-- 储值卡相关表
-- 版本: V16
-- 描述: 创建储值卡表和充值记录表,每个会员只有一张储值卡
-- ============================================
-- 储值卡表(包含支付密码)
CREATE TABLE IF NOT EXISTS member_stored_card (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL,
balance DECIMAL(10, 2) DEFAULT 0.00,
total_recharge DECIMAL(10, 2) DEFAULT 0.00,
total_consume DECIMAL(10, 2) DEFAULT 0.00,
pay_password VARCHAR(255),
is_pay_password_set BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_member_stored_card_member_id ON member_stored_card(member_id) WHERE deleted_at IS NULL;
COMMENT ON TABLE member_stored_card IS '储值卡表,每个会员只有一张储值卡,包含支付密码';
COMMENT ON COLUMN member_stored_card.member_id IS '会员用户ID';
COMMENT ON COLUMN member_stored_card.balance IS '储值卡余额';
COMMENT ON COLUMN member_stored_card.total_recharge IS '累计充值金额';
COMMENT ON COLUMN member_stored_card.total_consume IS '累计消费金额';
COMMENT ON COLUMN member_stored_card.pay_password IS '支付密码(BCrypt加密)';
COMMENT ON COLUMN member_stored_card.is_pay_password_set IS '是否已设置支付密码';
-- 储值卡充值记录表
CREATE TABLE IF NOT EXISTS member_stored_card_recharge (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
recharge_amount DECIMAL(10, 2) DEFAULT 0.00,
bonus_amount DECIMAL(10, 2) DEFAULT 0.00,
total_amount DECIMAL(10, 2) DEFAULT 0.00,
pay_amount DECIMAL(10, 2) DEFAULT 0.00,
status VARCHAR(32) DEFAULT 'PENDING',
pay_time TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stored_card_recharge_member_id ON member_stored_card_recharge(member_id) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_stored_card_recharge_order_no ON member_stored_card_recharge(order_no) WHERE deleted_at IS NULL;
COMMENT ON TABLE member_stored_card_recharge IS '储值卡充值记录表';
COMMENT ON COLUMN member_stored_card_recharge.member_id IS '会员用户ID';
COMMENT ON COLUMN member_stored_card_recharge.order_no IS '订单号';
COMMENT ON COLUMN member_stored_card_recharge.recharge_amount IS '充值金额';
COMMENT ON COLUMN member_stored_card_recharge.bonus_amount IS '赠送金额';
COMMENT ON COLUMN member_stored_card_recharge.total_amount IS '到账总金额(充值+赠送)';
COMMENT ON COLUMN member_stored_card_recharge.pay_amount IS '实际支付金额';
COMMENT ON COLUMN member_stored_card_recharge.status IS '充值状态:PENDING-待支付,SUCCESS-成功,FAILED-失败';
COMMENT ON COLUMN member_stored_card_recharge.pay_time IS '支付时间';
@@ -0,0 +1,8 @@
-- ============================================
-- 将 group_course_booking 表的 member_card_id 改为可空
-- 版本: V17
-- 描述: 预约团课不再需要会员卡,member_card_id 允许为空
-- ============================================
ALTER TABLE group_course_booking
ALTER COLUMN member_card_id DROP NOT NULL;
@@ -0,0 +1,209 @@
-- ============================================
-- 团课测试数据
-- 版本: V18
-- 描述: 插入团课标签、类型、关联关系及课程数据
-- 5个团课类型,14个标签,每类型3个标签,每类型5个团课
-- ============================================
-- ============================================
-- 1. 团课标签数据(14个)
-- ============================================
INSERT INTO course_label (label_name, color, description) VALUES
('适合新手', '#52c41a', '适合健身初学者,动作简单易学'),
('中级过渡', '#faad14', '适合有一定基础的学员,逐步提升难度'),
('高级进阶', '#f5222d', '适合高级学员,强度和技巧要求较高'),
('减脂塑形', '#722ed1', '高效燃烧卡路里,助力减脂和体型塑造'),
('增肌强化', '#13c2c2', '注重肌肉力量和围度增长'),
('柔韧性训练', '#eb2f96', '提升身体柔韧性和关节活动度'),
('核心训练', '#1890ff', '强化核心肌群,改善身体稳定性'),
('心肺训练', '#fa8c16', '提升心肺功能和耐力水平'),
('低冲击', '#52c41a', '低冲击运动,对关节友好'),
('高强度', '#f5222d', '高强度训练,短时间内最大化训练效果'),
('团体互动', '#722ed1', '注重团队协作和互动氛围'),
('私教推荐', '#13c2c2', '私教推荐的高效训练课程'),
('热门课程', '#ff1493', '人气较高,广受学员欢迎'),
('新课上线', '#00ced1', '新上线课程,欢迎尝鲜体验')
ON CONFLICT DO NOTHING;
-- ============================================
-- 2. 团课类型数据(5个)
-- ============================================
INSERT INTO group_course_type (type_name, base_difficulty, description, category) VALUES
('瑜伽', 3, '通过呼吸调控与体式练习,提升身体柔韧性、力量与心灵平静,适合各年龄段人群', '柔韧与平衡类'),
('动感单车', 5, '伴随动感音乐节奏进行室内骑行,高效燃烧卡路里,释放压力,体验极速快感', '基础有氧与热身'),
('HIIT训练', 8, '高强度间歇训练,通过短时爆发动作与间歇休息交替,短时间内达到最大燃脂效果', '高强度与爆发力'),
('普拉提', 4, '注重核心肌群控制、脊柱对齐和呼吸配合,有效改善体态,预防运动损伤', '柔韧与平衡类'),
('搏击操', 6, '融合拳击、踢腿、格斗动作的有氧运动,宣泄压力同时高效塑形,增强协调性', '高强度与爆发力')
ON CONFLICT DO NOTHING;
-- ============================================
-- 3. 类型-标签关联数据(每类型3个标签,共15条)
-- ============================================
INSERT INTO course_type_label (type_id, label_id)
SELECT gt.id, cl.id FROM group_course_type gt, course_label cl
WHERE gt.type_name = '瑜伽' AND cl.label_name = '柔韧性训练'
OR gt.type_name = '瑜伽' AND cl.label_name = '低冲击'
OR gt.type_name = '瑜伽' AND cl.label_name = '适合新手'
OR gt.type_name = '动感单车' AND cl.label_name = '心肺训练'
OR gt.type_name = '动感单车' AND cl.label_name = '热门课程'
OR gt.type_name = '动感单车' AND cl.label_name = '中级过渡'
OR gt.type_name = 'HIIT训练' AND cl.label_name = '高强度'
OR gt.type_name = 'HIIT训练' AND cl.label_name = '减脂塑形'
OR gt.type_name = 'HIIT训练' AND cl.label_name = '高级进阶'
OR gt.type_name = '普拉提' AND cl.label_name = '核心训练'
OR gt.type_name = '普拉提' AND cl.label_name = '柔韧性训练'
OR gt.type_name = '普拉提' AND cl.label_name = '适合新手'
OR gt.type_name = '搏击操' AND cl.label_name = '高强度'
OR gt.type_name = '搏击操' AND cl.label_name = '减脂塑形'
OR gt.type_name = '搏击操' AND cl.label_name = '心肺训练'
ON CONFLICT DO NOTHING;
-- ============================================
-- 4. 团课课程数据(5个类型 × 5个课程 = 25个)
-- start_time / end_time 分布在 2026-07-22 至 2026-07-28
-- ============================================
-- ---------- 瑜伽课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '哈他瑜伽入门', gt.id, '2026-07-22 08:00:00'::TIMESTAMP, '2026-07-22 09:00:00'::TIMESTAMP, 25, 12, '0', '瑜伽室A', '/static/course/yoga_hatha.jpg', '经典哈他瑜伽,从基础体式入手,配合呼吸引导,帮助初学者建立正确的瑜伽练习基础,感受身心的和谐统一。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '流瑜伽', gt.id, '2026-07-24 10:00:00'::TIMESTAMP, '2026-07-24 11:00:00'::TIMESTAMP, 20, 18, '0', '瑜伽室A', '/static/course/yoga_flow.jpg', '体式之间流畅串联,如行云流水般一气呵成。以拜日式为根基,结合站姿、平衡和扭转,提升力量与柔韧的整合能力。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '阴瑜伽', gt.id, '2026-07-26 18:00:00'::TIMESTAMP, '2026-07-26 19:15:00'::TIMESTAMP, 20, 8, '0', '瑜伽室B', '/static/course/yoga_yin.jpg', '阴瑜伽以长时间保持体式为特点,深度伸展筋膜和结缔组织,帮助你释放身体深层紧张,缓解一周的工作疲劳。适合所有级别。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '阿斯汤加瑜伽', gt.id, '2026-07-23 07:00:00'::TIMESTAMP, '2026-07-23 08:30:00'::TIMESTAMP, 15, 10, '0', '瑜伽室A', '/static/course/yoga_ashtanga.jpg', '阿斯汤加瑜伽以固定的体式序列和独特的呼吸法为核心,节奏紧凑,强度较高,能有效提升力量、柔韧和专注力。建议有一定瑜伽基础者参加。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '空中瑜伽', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 12, 12, '0', '瑜伽室B', '/static/course/yoga_aerial.jpg', '利用悬挂的瑜伽吊床完成各种体式,借助重力让脊柱自然延展,体验"飞翔"般的自由感。深受女性学员喜爱,名额有限请提前预约。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
-- ---------- 动感单车课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '初级燃脂骑行', gt.id, '2026-07-22 19:00:00'::TIMESTAMP, '2026-07-22 19:45:00'::TIMESTAMP, 30, 22, '0', '单车房', '/static/course/spin_beginner.jpg', '适合新手的入门级骑行课程,教练将带领你掌握正确的骑行姿势和阻力调节技巧。在动感音乐中轻松燃烧300-400卡路里。', 58.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '节奏爬坡', gt.id, '2026-07-24 18:30:00'::TIMESTAMP, '2026-07-24 19:15:00'::TIMESTAMP, 30, 15, '0', '单车房', '/static/course/spin_climb.jpg', '模拟山路爬坡骑行,通过逐渐增加阻力来挑战你的耐力和意志力。每一段爬坡后都有短暂的平路冲刺,节奏感十足,大汗淋漓的畅快体验。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '极速冲刺', gt.id, '2026-07-26 20:00:00'::TIMESTAMP, '2026-07-26 20:45:00'::TIMESTAMP, 25, 25, '0', '单车房', '/static/course/spin_sprint.jpg', '高强度冲刺为主题的骑行课,多组30秒极限冲刺搭配短暂恢复,彻底引爆你的心肺极限。周五夜间的爆款课程,约满速度极快!', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '耐力骑行', gt.id, '2026-07-27 10:00:00'::TIMESTAMP, '2026-07-27 11:00:00'::TIMESTAMP, 30, 18, '0', '单车房', '/static/course/spin_endurance.jpg', '60分钟中低强度耐力骑行,适合周末早晨唤醒身体。在稳定的节奏中持续燃脂,配以舒缓的收尾拉伸,开启元气满满的周末。', 58.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '音乐主题骑行', gt.id, '2026-07-23 19:30:00'::TIMESTAMP, '2026-07-23 20:15:00'::TIMESTAMP, 30, 20, '0', '单车房', '/static/course/spin_music.jpg', '以热门流行音乐为主题的骑行派对!每首歌曲对应一套骑行组合,跟着节拍加速、爬坡、冲刺,在音乐中忘记疲惫。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
-- ---------- HIIT训练课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT 'Tabata燃脂', gt.id, '2026-07-22 07:00:00'::TIMESTAMP, '2026-07-22 07:30:00'::TIMESTAMP, 15, 8, '0', '多功能训练区', '/static/course/hiit_tabata.jpg', '经典的Tabata模式:20秒全力运动 + 10秒休息,循环8轮共4分钟,搭配热身和拉伸,30分钟全身燃爆。早晨空腹训练燃脂效果加倍!', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '全身循环训练', gt.id, '2026-07-24 12:00:00'::TIMESTAMP, '2026-07-24 12:45:00'::TIMESTAMP, 15, 12, '0', '多功能训练区', '/static/course/hiit_circuit.jpg', '午间燃脂利器!8个动作站点轮流进行,涵盖深蹲跳、波比跳、壶铃摇摆、登山跑等,每个动作45秒,休息15秒,3轮循环让你全身湿透。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '核心爆发力', gt.id, '2026-07-26 17:30:00'::TIMESTAMP, '2026-07-26 18:15:00'::TIMESTAMP, 12, 5, '0', '多功能训练区', '/static/course/hiit_core.jpg', '专注于核心肌群的HIIT训练,结合药球砸地、悬垂举腿、俄罗斯转体和平板支撑变式,打造钢铁般的核心力量,提升所有运动表现。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '战绳挑战', gt.id, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 08:45:00'::TIMESTAMP, 10, 10, '0', '户外训练区', '/static/course/hiit_battle.jpg', '风靡全球的战绳训练!通过波浪、猛击、旋转等动作模式激活全身肌群,30秒一组高强度间歇,燃脂同时雕刻上肢线条。名额有限,已约满!', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '药球HIIT', gt.id, '2026-07-28 10:00:00'::TIMESTAMP, '2026-07-28 10:45:00'::TIMESTAMP, 12, 6, '0', '多功能训练区', '/static/course/hiit_medball.jpg', '以药球为主要工具的HIIT训练。药球砸地、旋转投掷、过头抛接等动作兼具力量和爆发力训练,动作多样不枯燥,周日上午的爆汗之选。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
-- ---------- 普拉提课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '垫上普拉提', gt.id, '2026-07-23 10:00:00'::TIMESTAMP, '2026-07-23 11:00:00'::TIMESTAMP, 20, 14, '0', '普拉提室', '/static/course/pilates_mat.jpg', '经典垫上普拉提,通过精准的动作控制激活深层核心肌群。从呼吸到骨盆稳定,从脊柱逐节卷动到四肢协调,一节课改善你的身体感知。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '器械普拉提', gt.id, '2026-07-25 09:00:00'::TIMESTAMP, '2026-07-25 10:00:00'::TIMESTAMP, 10, 8, '0', '普拉提室', '/static/course/pilates_reformer.jpg', '使用专业普拉提核心床(Reformer)进行训练,弹簧阻力提供精准的负荷控制,适合需要针对性改善体态、康复训练或追求高效塑形的学员。', 158.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '核心塑形', gt.id, '2026-07-27 14:00:00'::TIMESTAMP, '2026-07-27 15:00:00'::TIMESTAMP, 15, 9, '0', '普拉提室', '/static/course/pilates_sculpt.jpg', '专注于腹部、腰部和臀部的普拉提塑形课程。百次拍击、剪刀腿、肩桥等经典动作持续刺激目标肌群,打造紧致核心线条。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '脊柱健康', gt.id, '2026-07-22 16:00:00'::TIMESTAMP, '2026-07-22 17:00:00'::TIMESTAMP, 15, 6, '0', '普拉提室', '/static/course/pilates_spine.jpg', '专为久坐办公人群设计,通过普拉提脊柱逐节运动改善驼背、圆肩等不良体态。融合猫牛式、脊柱旋转和游泳式,给你的脊柱一次深度保养。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '产后恢复普拉提', gt.id, '2026-07-24 11:00:00'::TIMESTAMP, '2026-07-24 12:00:00'::TIMESTAMP, 10, 4, '0', '普拉提室', '/static/course/pilates_postnatal.jpg', '专为产后妈妈设计的温和修复课程,重点激活盆底肌和腹横肌,循序渐进恢复核心力量。小班教学,教练一对一关注每位学员的姿势质量。', 158.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
-- ---------- 搏击操课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '有氧搏击基础', gt.id, '2026-07-23 18:00:00'::TIMESTAMP, '2026-07-23 19:00:00'::TIMESTAMP, 25, 20, '0', '操厅A', '/static/course/boxing_basic.jpg', '零基础友好的搏击操入门课。从直拳、摆拳、勾拳到前踢、侧踹,教练分步讲解每个动作的要领,配合动感音乐串联成完整的搏击组合。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT 'Kickboxing进阶', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 20, 15, '0', '操厅A', '/static/course/boxing_kickboxing.jpg', '融合踢拳技术的进阶搏击课程,增加连击组合、闪躲步法和膝肘技术。强度较基础课明显提升,汗如雨下的同时释放工作压力,宣泄效果一流。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '综合格斗体能', gt.id, '2026-07-27 16:00:00'::TIMESTAMP, '2026-07-27 17:00:00'::TIMESTAMP, 20, 12, '0', '操厅A', '/static/course/boxing_mma.jpg', '融合拳击、泰拳、摔跤元素的综合体能训练。沙袋击打、地面对抗和爆发力训练交替进行,全面提升力量、速度和耐力。周六下午的硬核之选!', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '燃脂拳击', gt.id, '2026-07-22 12:00:00'::TIMESTAMP, '2026-07-22 13:00:00'::TIMESTAMP, 25, 16, '0', '操厅A', '/static/course/boxing_fatburn.jpg', '午间燃脂拳击课。空击组合与沙袋击打间歇进行,心率维持在高燃脂区间。一节课可消耗500-700卡路里,中午来打拳比喝咖啡更提神!', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '泰拳基础', gt.id, '2026-07-28 15:00:00'::TIMESTAMP, '2026-07-28 16:00:00'::TIMESTAMP, 15, 7, '0', '操厅A', '/static/course/boxing_muaythai.jpg', '学习泰拳的八肢艺术(双拳、双腿、双膝、双肘),从基本站架到肘膝组合,感受泰拳的刚猛魅力。小班教学保证动作纠正质量。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
-- ============================================
-- 5. 团课推荐数据(每类型推荐1个热门课程,共5条)
-- ============================================
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active)
SELECT gc.id, '清晨之选 - 哈他瑜伽入门', '周一早晨8点,用温和的瑜伽唤醒身体,开启高效一周', '最受欢迎的入门瑜伽课,教练耐心细致', 5, TRUE
FROM group_course gc WHERE gc.course_name = '哈他瑜伽入门';
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active)
SELECT gc.id, '周五狂欢 - 极速冲刺', '周五晚8点,用极速骑行释放一周的疲惫与压力', '单车房王牌课程,周周爆满,燃烧500+卡路里', 4, TRUE
FROM group_course gc WHERE gc.course_name = '极速冲刺';
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active)
SELECT gc.id, '午间爆汗 - 全身循环训练', '午休45分钟,8个站点循环,高效利用碎片时间燃脂', '午间最受欢迎的HIIT课,适合忙碌的上班族', 3, TRUE
FROM group_course gc WHERE gc.course_name = '全身循环训练';
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active)
SELECT gc.id, '久坐救星 - 脊柱健康普拉提', '专为久坐族设计的脊柱保养课,改善驼背圆肩', '全网好评,学员反馈"一节课腰就不疼了"', 2, TRUE
FROM group_course gc WHERE gc.course_name = '脊柱健康';
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active)
SELECT gc.id, '释压首选 - 有氧搏击基础', '零基础也能打!在拳拳到肉的快感中忘记烦恼', '新手友好,发泄压力效果拔群,超过200名学员推荐', 1, TRUE
FROM group_course gc WHERE gc.course_name = '有氧搏击基础';
@@ -0,0 +1,315 @@
-- ============================================
-- V19: 系统初始数据(角色/用户/权限/菜单/字典/配置)
-- 来源:合并原 V2 + V22 + V28 + V29 + V30 + V31
-- ============================================
-- ============================================
-- 1. 角色数据
-- ============================================
INSERT INTO sys_role (id, role_name, role_key, role_sort, status, create_by, update_by, created_at, updated_at)
VALUES
(1, '超级管理员', 'admin', 1, 1, 'system', 'system', NOW(), NOW()),
(2, '测试管理员', 'test_admin', 2, 1, 'system', 'system', NOW(), NOW()),
(3, '普通用户', 'normal_user', 3, 1, 'system', 'system', NOW(), NOW()),
(4, '访客', 'guest', 4, 1, 'system', 'system', NOW(), NOW());
SELECT setval('sys_role_id_seq', 4);
-- ============================================
-- 2. 用户数据(密码均为 Test@123)
-- ============================================
INSERT INTO sys_user (id, username, password, email, phone, nickname, status, create_by, update_by, created_at, updated_at)
VALUES
(1, 'admin', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'admin@novalon.com', '13800138000', '超级管理员', 1, 'system', 'system', NOW(), NOW()),
(2, 'user', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'user@novalon.com', '13800138001', '普通用户', 1, 'system', 'system', NOW(), NOW()),
(10,'e2e_test_user', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'e2e@test.com', '13900139000', 'E2E测试用户', 1, 'system', 'system', NOW(), NOW())
ON CONFLICT (username) DO UPDATE SET
password = EXCLUDED.password,
status = EXCLUDED.status;
SELECT setval('sys_user_id_seq', 10);
-- ============================================
-- 3. 用户角色关联
-- ============================================
INSERT INTO user_role (user_id, role_id, created_by, created_at)
VALUES
(1, 1, 'system', NOW()),
(2, 3, 'system', NOW()),
(10, 1, 'system', NOW())
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ============================================
-- 4. 权限数据
-- ============================================
-- 4.1 系统管理权限
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
('用户查看', 'system:user:view', '/api/users', 'GET', '查看用户列表', 1, 'system', 'system', NOW(), NOW()),
('用户创建', 'system:user:create', '/api/users', 'POST', '创建用户', 1, 'system', 'system', NOW(), NOW()),
('用户编辑', 'system:user:edit', '/api/users', 'PUT', '编辑用户', 1, 'system', 'system', NOW(), NOW()),
('用户删除', 'system:user:delete', '/api/users', 'DELETE', '删除用户', 1, 'system', 'system', NOW(), NOW()),
('角色查看', 'system:role:view', '/api/roles', 'GET', '查看角色列表', 1, 'system', 'system', NOW(), NOW()),
('角色创建', 'system:role:create', '/api/roles', 'POST', '创建角色', 1, 'system', 'system', NOW(), NOW()),
('角色编辑', 'system:role:edit', '/api/roles', 'PUT', '编辑角色', 1, 'system', 'system', NOW(), NOW()),
('角色删除', 'system:role:delete', '/api/roles', 'DELETE', '删除角色', 1, 'system', 'system', NOW(), NOW()),
('角色分配权限', 'system:role:assign', '/api/roles/*/permissions', 'POST', '为角色分配权限', 1, 'system', 'system', NOW(), NOW()),
('权限查看', 'system:permission:view', '/api/permissions', 'GET', '查看权限列表', 1, 'system', 'system', NOW(), NOW()),
('权限创建', 'system:permission:create', '/api/permissions', 'POST', '创建权限', 1, 'system', 'system', NOW(), NOW()),
('权限编辑', 'system:permission:edit', '/api/permissions', 'PUT', '编辑权限', 1, 'system', 'system', NOW(), NOW()),
('权限删除', 'system:permission:delete', '/api/permissions', 'DELETE', '删除权限', 1, 'system', 'system', NOW(), NOW()),
('菜单查看', 'system:menu:view', '/api/menus', 'GET', '查看菜单列表', 1, 'system', 'system', NOW(), NOW()),
('菜单创建', 'system:menu:create', '/api/menus', 'POST', '创建菜单', 1, 'system', 'system', NOW(), NOW()),
('菜单编辑', 'system:menu:edit', '/api/menus', 'PUT', '编辑菜单', 1, 'system', 'system', NOW(), NOW()),
('菜单删除', 'system:menu:delete', '/api/menus', 'DELETE', '删除菜单', 1, 'system', 'system', NOW(), NOW()),
('字典查看', 'system:dict:view', '/api/dict', 'GET', '查看字典列表', 1, 'system', 'system', NOW(), NOW()),
('字典创建', 'system:dict:create', '/api/dict', 'POST', '创建字典', 1, 'system', 'system', NOW(), NOW()),
('字典编辑', 'system:dict:edit', '/api/dict', 'PUT', '编辑字典', 1, 'system', 'system', NOW(), NOW()),
('字典删除', 'system:dict:delete', '/api/dict', 'DELETE', '删除字典', 1, 'system', 'system', NOW(), NOW()),
('配置查看', 'system:config:view', '/api/config', 'GET', '查看系统配置', 1, 'system', 'system', NOW(), NOW()),
('配置创建', 'system:config:create', '/api/config', 'POST', '创建系统配置', 1, 'system', 'system', NOW(), NOW()),
('配置编辑', 'system:config:edit', '/api/config', 'PUT', '编辑系统配置', 1, 'system', 'system', NOW(), NOW()),
('配置删除', 'system:config:delete', '/api/config', 'DELETE', '删除系统配置', 1, 'system', 'system', NOW(), NOW()),
('日志查看', 'system:log:view', '/api/logs', 'GET', '查看日志', 1, 'system', 'system', NOW(), NOW()),
('文件上传', 'system:file:upload', '/api/files/upload', 'POST', '上传文件', 1, 'system', 'system', NOW(), NOW()),
('文件下载', 'system:file:download', '/api/files/download', 'GET', '下载文件', 1, 'system', 'system', NOW(), NOW()),
('文件删除', 'system:file:delete', '/api/files', 'DELETE', '删除文件', 1, 'system', 'system', NOW(), NOW()),
('公告查看', 'system:notice:view', '/api/notices', 'GET', '查看公告', 1, 'system', 'system', NOW(), NOW()),
('公告创建', 'system:notice:create', '/api/notices', 'POST', '创建公告', 1, 'system', 'system', NOW(), NOW()),
('公告编辑', 'system:notice:edit', '/api/notices', 'PUT', '编辑公告', 1, 'system', 'system', NOW(), NOW()),
('公告删除', 'system:notice:delete', '/api/notices', 'DELETE', '删除公告', 1, 'system', 'system', NOW(), NOW());
-- 4.2 业务模块权限
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
-- 会员管理
('会员查看', 'business:member:view', '/api/admin/members', 'GET', '查看会员列表及详情', 1, 'system', 'system', NOW(), NOW()),
('会员编辑', 'business:member:edit', '/api/admin/member', 'PUT', '编辑会员信息', 1, 'system', 'system', NOW(), NOW()),
('会员手机修改', 'business:member:phone', '/api/admin/member/phone', 'POST', '管理员修改会员手机号', 1, 'system', 'system', NOW(), NOW()),
-- 会员卡管理
('会员卡查看', 'business:memberCard:view', '/api/member-cards', 'GET', '查看会员卡列表及详情', 1, 'system', 'system', NOW(), NOW()),
('会员卡创建', 'business:memberCard:create', '/api/member-cards', 'POST', '创建会员卡类型', 1, 'system', 'system', NOW(), NOW()),
('会员卡编辑', 'business:memberCard:edit', '/api/member-cards', 'PUT', '编辑会员卡类型', 1, 'system', 'system', NOW(), NOW()),
('会员卡删除', 'business:memberCard:delete', '/api/member-cards', 'DELETE', '删除会员卡类型', 1, 'system', 'system', NOW(), NOW()),
('会员卡购买', 'business:memberCard:purchase', '/api/member-card-records/purchase', 'POST', '为会员购买/绑定会员卡', 1, 'system', 'system', NOW(), NOW()),
('会员卡退卡', 'business:memberCard:refund', '/api/member-card-records/refund', 'POST', '会员卡退卡/解绑', 1, 'system', 'system', NOW(), NOW()),
-- 团课管理
('团课查看', 'business:groupCourse:view', '/api/groupCourse', 'GET', '查看团课列表及详情', 1, 'system', 'system', NOW(), NOW()),
('团课创建', 'business:groupCourse:create', '/api/groupCourse', 'POST', '创建团课', 1, 'system', 'system', NOW(), NOW()),
('团课编辑', 'business:groupCourse:edit', '/api/groupCourse', 'PUT', '编辑团课信息', 1, 'system', 'system', NOW(), NOW()),
('团课删除', 'business:groupCourse:delete', '/api/groupCourse', 'DELETE', '删除团课', 1, 'system', 'system', NOW(), NOW()),
('团课取消', 'business:groupCourse:cancel', '/api/groupCourse/cancel', 'POST', '取消团课', 1, 'system', 'system', NOW(), NOW()),
-- 团课类型管理
('团课类型查看', 'business:groupCourseType:view', '/api/groupCourse/types', 'GET', '查看团课类型列表', 1, 'system', 'system', NOW(), NOW()),
('团课类型创建', 'business:groupCourseType:create', '/api/groupCourse/types', 'POST', '创建团课类型', 1, 'system', 'system', NOW(), NOW()),
('团课类型编辑', 'business:groupCourseType:edit', '/api/groupCourse/types', 'PUT', '编辑团课类型', 1, 'system', 'system', NOW(), NOW()),
('团课类型删除', 'business:groupCourseType:delete', '/api/groupCourse/types', 'DELETE', '删除团课类型', 1, 'system', 'system', NOW(), NOW()),
-- 团课推荐管理
('团课推荐查看', 'business:groupCourseRecommend:view', '/api/groupCourse/recommend', 'GET', '查看推荐团课列表', 1, 'system', 'system', NOW(), NOW()),
('团课推荐创建', 'business:groupCourseRecommend:create','/api/groupCourse/recommend', 'POST', '创建推荐团课', 1, 'system', 'system', NOW(), NOW()),
('团课推荐编辑', 'business:groupCourseRecommend:edit', '/api/groupCourse/recommend', 'PUT', '编辑推荐团课', 1, 'system', 'system', NOW(), NOW()),
('团课推荐删除', 'business:groupCourseRecommend:delete','/api/groupCourse/recommend', 'DELETE', '删除推荐团课', 1, 'system', 'system', NOW(), NOW()),
-- 团课预约管理
('团课预约查看', 'business:groupCourseBooking:view', '/api/groupCourse/bookings', 'GET', '查看团课预约记录', 1, 'system', 'system', NOW(), NOW()),
('团课预约创建', 'business:groupCourseBooking:create', '/api/groupCourse/book', 'POST', '预约团课', 1, 'system', 'system', NOW(), NOW()),
('团课预约取消', 'business:groupCourseBooking:cancel', '/api/groupCourse/booking/cancel', 'POST', '取消团课预约', 1, 'system', 'system', NOW(), NOW()),
-- 签到管理
('签到查看', 'business:checkIn:view', '/api/checkIn/records', 'GET', '查看签到记录', 1, 'system', 'system', NOW(), NOW()),
('签到操作', 'business:checkIn:create', '/api/checkIn', 'POST', '执行签到操作', 1, 'system', 'system', NOW(), NOW()),
('签到导出', 'business:checkIn:export', '/api/checkIn/records/export', 'GET', '导出签到记录', 1, 'system', 'system', NOW(), NOW()),
-- 数据统计
('数据统计查看', 'business:dataCount:view', '/api/datacount', 'GET', '查看数据统计报表', 1, 'system', 'system', NOW(), NOW()),
('数据统计导出', 'business:dataCount:export', '/api/datacount/export', 'GET', '导出统计报表', 1, 'system', 'system', NOW(), NOW()),
-- 员工管理
('员工查看', 'business:employee:view', '/api/employees/page', 'GET', '查看员工列表', 1, 'system', 'system', NOW(), NOW()),
('员工创建', 'business:employee:create', '/api/users', 'POST', '创建员工账号', 1, 'system', 'system', NOW(), NOW()),
('员工编辑', 'business:employee:edit', '/api/users', 'PUT', '编辑员工信息', 1, 'system', 'system', NOW(), NOW()),
('员工删除', 'business:employee:delete', '/api/users', 'DELETE', '删除/禁用员工账号', 1, 'system', 'system', NOW(), NOW()),
('员工分配角色', 'business:employee:assignRole', '/api/users/roles', 'POST', '为员工分配角色', 1, 'system', 'system', NOW(), NOW());
-- ============================================
-- 5. 为超级管理员角色分配所有权限
-- ============================================
INSERT INTO sys_role_permission (role_id, permission_id, create_by, update_by, created_at, updated_at)
SELECT 1, id, 'system', 'system', NOW(), NOW() FROM sys_permission WHERE status = 1
AND id NOT IN (SELECT permission_id FROM sys_role_permission WHERE role_id = 1);
-- ============================================
-- 6. 菜单数据
-- ============================================
-- 6.1 一级菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(1, '系统管理', 0, 1, 'M', NULL, NULL, 1, NOW(), NOW()),
(2, '审计日志', 0, 2, 'M', NULL, NULL, 1, NOW(), NOW()),
(3, '系统监控', 0, 3, 'M', NULL, NULL, 1, NOW(), NOW()),
(400, '团课管理', 0, 4, 'M', NULL, NULL, 1, NOW(), NOW()),
(500, '会员管理', 0, 5, 'M', NULL, NULL, 1, NOW(), NOW()),
(502, '会员卡管理', 0, 6, 'M', NULL, NULL, 1, NOW(), NOW()),
(504, '数据统计', 0, 7, 'M', NULL, NULL, 1, NOW(), NOW());
-- 6.2 系统管理 - 子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(11, '用户管理', 1, 1, 'C', 'system:user:list', 'system/user/index', 1, NOW(), NOW()),
(12, '角色管理', 1, 2, 'C', 'system:role:list', 'system/role/index', 1, NOW(), NOW()),
(13, '菜单管理', 1, 3, 'C', 'system:menu:list', 'system/menu/index', 1, NOW(), NOW()),
(14, '部门管理', 1, 4, 'C', 'system:dept:list', 'system/dept/index', 1, NOW(), NOW()),
(15, '字典管理', 1, 5, 'C', 'system:dict:list', 'system/dict/index', 1, NOW(), NOW()),
(16, '参数管理', 1, 6, 'C', 'system:config:list', 'system/config/index', 1, NOW(), NOW()),
(17, '通知公告', 1, 7, 'C', 'system:notice:list', 'system/notice/index', 1, NOW(), NOW()),
(18, '文件管理', 1, 8, 'C', 'system:file:list', 'system/file/index', 1, NOW(), NOW()),
(19, '轮播图管理', 0, 8, 'C', 'system:banner:list', 'banner/index', 1, NOW(), NOW());
-- 6.3 系统管理 - 按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
-- 用户管理按钮
(111, '用户查询', 11, 1, 'F', 'system:user:query', NULL, 1, NOW(), NOW()),
(112, '用户新增', 11, 2, 'F', 'system:user:add', NULL, 1, NOW(), NOW()),
(113, '用户修改', 11, 3, 'F', 'system:user:edit', NULL, 1, NOW(), NOW()),
(114, '用户删除', 11, 4, 'F', 'system:user:remove', NULL, 1, NOW(), NOW()),
(115, '用户导出', 11, 5, 'F', 'system:user:export', NULL, 1, NOW(), NOW()),
(116, '用户导入', 11, 6, 'F', 'system:user:import', NULL, 1, NOW(), NOW()),
(117, '重置密码', 11, 7, 'F', 'system:user:resetPwd', NULL, 1, NOW(), NOW()),
-- 角色管理按钮
(121, '角色查询', 12, 1, 'F', 'system:role:query', NULL, 1, NOW(), NOW()),
(122, '角色新增', 12, 2, 'F', 'system:role:add', NULL, 1, NOW(), NOW()),
(123, '角色修改', 12, 3, 'F', 'system:role:edit', NULL, 1, NOW(), NOW()),
(124, '角色删除', 12, 4, 'F', 'system:role:remove', NULL, 1, NOW(), NOW()),
(125, '角色导出', 12, 5, 'F', 'system:role:export', NULL, 1, NOW(), NOW()),
-- 菜单管理按钮
(131, '菜单查询', 13, 1, 'F', 'system:menu:query', NULL, 1, NOW(), NOW()),
(132, '菜单新增', 13, 2, 'F', 'system:menu:add', NULL, 1, NOW(), NOW()),
(133, '菜单修改', 13, 3, 'F', 'system:menu:edit', NULL, 1, NOW(), NOW()),
(134, '菜单删除', 13, 4, 'F', 'system:menu:remove', NULL, 1, NOW(), NOW());
-- 6.4 审计日志 - 子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(21, '操作日志', 2, 1, 'C', 'audit:operation:list', 'audit/operation/index', 1, NOW(), NOW()),
(22, '登录日志', 2, 2, 'C', 'audit:login:list', 'audit/login/index', 1, NOW(), NOW()),
(23, '异常日志', 2, 3, 'C', 'audit:exception:list', 'audit/exception/index', 1, NOW(), NOW());
-- 6.5 审计日志 - 按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(211, '操作查询', 21, 1, 'F', 'audit:operation:query', NULL, 1, NOW(), NOW()),
(212, '操作删除', 21, 2, 'F', 'audit:operation:remove', NULL, 1, NOW(), NOW()),
(213, '操作导出', 21, 3, 'F', 'audit:operation:export', NULL, 1, NOW(), NOW()),
(221, '登录查询', 22, 1, 'F', 'audit:login:query', NULL, 1, NOW(), NOW()),
(222, '登录删除', 22, 2, 'F', 'audit:login:remove', NULL, 1, NOW(), NOW()),
(223, '登录导出', 22, 3, 'F', 'audit:login:export', NULL, 1, NOW(), NOW()),
(231, '异常查询', 23, 1, 'F', 'audit:exception:query', NULL, 1, NOW(), NOW()),
(232, '异常删除', 23, 2, 'F', 'audit:exception:remove', NULL, 1, NOW(), NOW()),
(233, '异常导出', 23, 3, 'F', 'audit:exception:export', NULL, 1, NOW(), NOW());
-- 6.6 系统监控 - 子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(31, '在线用户', 3, 1, 'C', 'monitor:online:list', 'monitor/online/index', 1, NOW(), NOW()),
(32, '定时任务', 3, 2, 'C', 'monitor:job:list', 'monitor/job/index', 1, NOW(), NOW()),
(33, '数据监控', 3, 3, 'C', 'monitor:data:list', 'monitor/data/index', 1, NOW(), NOW()),
(34, '服务监控', 3, 4, 'C', 'monitor:server:list', 'monitor/server/index', 1, NOW(), NOW()),
(35, '缓存监控', 3, 5, 'C', 'monitor:cache:list', 'monitor/cache/index', 1, NOW(), NOW());
-- 6.7 系统监控 - 按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(311, '在线查询', 31, 1, 'F', 'monitor:online:query', NULL, 1, NOW(), NOW()),
(312, '在线强退', 31, 2, 'F', 'monitor:online:forceLogout', NULL, 1, NOW(), NOW()),
(321, '任务查询', 32, 1, 'F', 'monitor:job:query', NULL, 1, NOW(), NOW()),
(322, '任务新增', 32, 2, 'F', 'monitor:job:add', NULL, 1, NOW(), NOW()),
(323, '任务修改', 32, 3, 'F', 'monitor:job:edit', NULL, 1, NOW(), NOW()),
(324, '任务删除', 32, 4, 'F', 'monitor:job:remove', NULL, 1, NOW(), NOW()),
(325, '任务执行', 32, 5, 'F', 'monitor:job:execute', NULL, 1, NOW(), NOW());
-- 6.8 团课管理 - 子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(401, '团课管理', 400, 1, 'C', 'business:groupCourse:list', 'gymCourse/course/index', 1, NOW(), NOW()),
(402, '团课类型', 400, 2, 'C', 'business:groupCourseType:list', 'gymCourse/type/index', 1, NOW(), NOW()),
(403, '类型标签', 400, 3, 'C', 'business:groupCourseLabel:list', 'gymCourse/label/index', 1, NOW(), NOW()),
(404, '团课推荐', 400, 4, 'C', 'business:groupCourseRecommend:list', 'gymCourse/recommend/index', 1, NOW(), NOW());
-- 6.9 团课管理 - 按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(4011, '团课查询', 401, 1, 'F', 'business:groupCourse:query', NULL, 1, NOW(), NOW()),
(4012, '团课新增', 401, 2, 'F', 'business:groupCourse:add', NULL, 1, NOW(), NOW()),
(4013, '团课修改', 401, 3, 'F', 'business:groupCourse:edit', NULL, 1, NOW(), NOW()),
(4014, '团课删除', 401, 4, 'F', 'business:groupCourse:remove', NULL, 1, NOW(), NOW()),
(4015, '团课取消', 401, 5, 'F', 'business:groupCourse:cancel', NULL, 1, NOW(), NOW()),
(4021, '类型查询', 402, 1, 'F', 'business:groupCourseType:query', NULL, 1, NOW(), NOW()),
(4022, '类型新增', 402, 2, 'F', 'business:groupCourseType:add', NULL, 1, NOW(), NOW()),
(4023, '类型修改', 402, 3, 'F', 'business:groupCourseType:edit', NULL, 1, NOW(), NOW()),
(4024, '类型删除', 402, 4, 'F', 'business:groupCourseType:remove', NULL, 1, NOW(), NOW()),
(4031, '标签查询', 403, 1, 'F', 'business:groupCourseLabel:query', NULL, 1, NOW(), NOW()),
(4032, '标签新增', 403, 2, 'F', 'business:groupCourseLabel:add', NULL, 1, NOW(), NOW()),
(4033, '标签修改', 403, 3, 'F', 'business:groupCourseLabel:edit', NULL, 1, NOW(), NOW()),
(4034, '标签删除', 403, 4, 'F', 'business:groupCourseLabel:remove', NULL, 1, NOW(), NOW()),
(4041, '推荐查询', 404, 1, 'F', 'business:groupCourseRecommend:query', NULL, 1, NOW(), NOW()),
(4042, '推荐新增', 404, 2, 'F', 'business:groupCourseRecommend:add', NULL, 1, NOW(), NOW()),
(4043, '推荐修改', 404, 3, 'F', 'business:groupCourseRecommend:edit', NULL, 1, NOW(), NOW()),
(4044, '推荐删除', 404, 4, 'F', 'business:groupCourseRecommend:remove', NULL, 1, NOW(), NOW());
-- 6.10 会员管理 - 子菜单
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(501, '会员管理', 500, 1, 'C', 'business:member:list', 'member/member/index', 1, NOW(), NOW()),
(503, '会员卡管理', 502, 1, 'C', 'business:memberCard:list', 'member/card/index', 1, NOW(), NOW()),
(505, '数据统计看板', 504, 1, 'C', 'business:statistics:view', 'statistics/dashboard/index', 1, NOW(), NOW());
-- 6.11 会员管理 - 按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(5011, '会员查询', 501, 1, 'F', 'business:member:query', NULL, 1, NOW(), NOW()),
(5012, '会员新增', 501, 2, 'F', 'business:member:add', NULL, 1, NOW(), NOW()),
(5013, '会员修改', 501, 3, 'F', 'business:member:edit', NULL, 1, NOW(), NOW()),
(5014, '会员删除', 501, 4, 'F', 'business:member:remove', NULL, 1, NOW(), NOW()),
(5015, '会员详情', 501, 5, 'F', 'business:member:detail', NULL, 1, NOW(), NOW()),
(5031, '会员卡查询', 503, 1, 'F', 'business:memberCard:query', NULL, 1, NOW(), NOW()),
(5032, '会员卡新增', 503, 2, 'F', 'business:memberCard:add', NULL, 1, NOW(), NOW()),
(5033, '会员卡修改', 503, 3, 'F', 'business:memberCard:edit', NULL, 1, NOW(), NOW()),
(5034, '会员卡删除', 503, 4, 'F', 'business:memberCard:remove', NULL, 1, NOW(), NOW()),
(5051, '数据统计导出', 505, 1, 'F', 'business:statistics:export', NULL, 1, NOW(), NOW());
-- ============================================
-- 7. 字典数据
-- ============================================
-- 7.1 字典类型
INSERT INTO sys_dict_type (dict_name, dict_type, status, remark, create_by, update_by, created_at, updated_at)
VALUES
('用户状态', 'user_status', '0', '用户状态列表', 'system', 'system', NOW(), NOW()),
('菜单状态', 'menu_status', '0', '菜单状态列表', 'system', 'system', NOW(), NOW()),
('角色状态', 'role_status', '0', '角色状态列表', 'system', 'system', NOW(), NOW()),
('系统开关', 'sys_normal_disable', '0', '系统开关列表', 'system', 'system', NOW(), NOW())
ON CONFLICT (dict_type) DO NOTHING;
-- 7.2 字典数据
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, update_by, created_at, updated_at)
VALUES
(1, '正常', '1', 'user_status', '', 'primary', 'Y', '0', 'system', 'system', NOW(), NOW()),
(2, '停用', '0', 'user_status', '', 'danger', 'N', '0', 'system', 'system', NOW(), NOW()),
(1, '正常', '0', 'menu_status', '', 'primary', 'Y', '0', 'system', 'system', NOW(), NOW()),
(2, '停用', '1', 'menu_status', '', 'danger', 'N', '0', 'system', 'system', NOW(), NOW()),
(1, '正常', '0', 'role_status', '', 'primary', 'Y', '0', 'system', 'system', NOW(), NOW()),
(2, '停用', '1', 'role_status', '', 'danger', 'N', '0', 'system', 'system', NOW(), NOW()),
(1, '正常', '0', 'sys_normal_disable', '', 'primary', 'Y', '0', 'system', 'system', NOW(), NOW()),
(2, '停用', '1', 'sys_normal_disable', '', 'danger', 'N', '0', 'system', 'system', NOW(), NOW());
-- ============================================
-- 8. 系统配置
-- ============================================
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, update_by, created_at, updated_at)
VALUES
('用户管理-用户初始密码', 'sys.user.initPassword', '123456', 'Y', 'system', 'system', NOW(), NOW()),
('主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue','Y', 'system', 'system', NOW(), NOW()),
('用户自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'system', 'system', NOW(), NOW()),
('用户自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'system', 'system', NOW(), NOW()),
('账号自助-密码验证码', 'sys.account.pwdCaptchaEnabled','true', 'Y', 'system', 'system', NOW(), NOW()),
('团课-取消预约免手续费次数', 'group_course.cancel_free_limit','3', 'Y', 'system', 'system', NOW(), NOW())
ON CONFLICT (config_key) DO NOTHING;
-- ============================================
-- 9. 重置所有序列值
-- ============================================
SELECT setval('sys_user_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_user));
SELECT setval('sys_role_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_role));
SELECT setval('user_role_id_seq', (SELECT COALESCE(MAX(id), 1) FROM user_role));
SELECT setval('sys_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_permission));
SELECT setval('sys_role_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_role_permission));
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
SELECT setval('sys_dict_type_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_dict_type));
SELECT setval('sys_dict_data_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_dict_data));
SELECT setval('sys_config_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_config));
@@ -1,6 +1,8 @@
-- ============================================
-- Novalon管理系统数据库初始化脚本 -- Novalon管理系统数据库初始化脚本
-- 版本: V1 -- 版本: V1
-- 描述: 创建所有核心表结构(合并版) -- 描述: 创建所有核心系统表结构
-- ============================================
-- ============================================ -- ============================================
-- 用户与角色相关表 -- 用户与角色相关表
@@ -140,7 +142,7 @@ CREATE TABLE IF NOT EXISTS sys_dict_data (
deleted_at TIMESTAMP deleted_at TIMESTAMP
); );
-- 字典表(通用字典 -- 通用字典
CREATE TABLE IF NOT EXISTS sys_dictionary ( CREATE TABLE IF NOT EXISTS sys_dictionary (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
type VARCHAR(100) NOT NULL, type VARCHAR(100) NOT NULL,
@@ -165,7 +167,7 @@ CREATE TABLE IF NOT EXISTS sys_config (
config_name VARCHAR(100) NOT NULL, config_name VARCHAR(100) NOT NULL,
config_key VARCHAR(100) NOT NULL UNIQUE, config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(500) NOT NULL, config_value VARCHAR(500) NOT NULL,
config_type VARCHAR(1) DEFAULT 'N', config_type VARCHAR(1) DEFAULT 'Y',
create_by VARCHAR(50), create_by VARCHAR(50),
update_by VARCHAR(50), update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -355,15 +357,6 @@ COMMENT ON TABLE sys_dict_data IS '字典数据表';
COMMENT ON TABLE sys_dictionary IS '通用字典表'; COMMENT ON TABLE sys_dictionary IS '通用字典表';
COMMENT ON TABLE sys_config IS '系统配置表'; COMMENT ON TABLE sys_config IS '系统配置表';
COMMENT ON TABLE sys_login_log IS '登录日志表'; COMMENT ON TABLE sys_login_log IS '登录日志表';
COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON TABLE operation_log IS '操作日志表';
COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON TABLE sys_notice IS '系统公告表';
COMMENT ON TABLE sys_user_message IS '用户消息表';
COMMENT ON TABLE sys_file IS '文件管理表';
COMMENT ON TABLE oauth2_client IS 'OAuth2客户端表';
COMMENT ON TABLE sys_exception_log IS '异常日志表'; COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON COLUMN sys_exception_log.id IS '主键ID'; COMMENT ON COLUMN sys_exception_log.id IS '主键ID';
COMMENT ON COLUMN sys_exception_log.username IS '操作用户'; COMMENT ON COLUMN sys_exception_log.username IS '操作用户';
@@ -375,7 +368,22 @@ COMMENT ON COLUMN sys_exception_log.exception_msg IS '异常消息';
COMMENT ON COLUMN sys_exception_log.exception_stack IS '异常堆栈'; COMMENT ON COLUMN sys_exception_log.exception_stack IS '异常堆栈';
COMMENT ON COLUMN sys_exception_log.ip IS 'IP地址'; COMMENT ON COLUMN sys_exception_log.ip IS 'IP地址';
COMMENT ON COLUMN sys_exception_log.create_time IS '创建时间'; COMMENT ON COLUMN sys_exception_log.create_time IS '创建时间';
COMMENT ON TABLE operation_log IS '操作日志表';
COMMENT ON COLUMN operation_log.id IS '主键ID';
COMMENT ON COLUMN operation_log.username IS '操作用户';
COMMENT ON COLUMN operation_log.operation IS '操作描述';
COMMENT ON COLUMN operation_log.method IS '请求方法';
COMMENT ON COLUMN operation_log.params IS '请求参数';
COMMENT ON COLUMN operation_log.result IS '操作结果';
COMMENT ON COLUMN operation_log.ip IS 'IP地址';
COMMENT ON COLUMN operation_log.duration IS '执行时长(毫秒)';
COMMENT ON COLUMN operation_log.status IS '操作状态(0成功 1失败)';
COMMENT ON COLUMN operation_log.error_msg IS '错误消息';
COMMENT ON COLUMN operation_log.create_by IS '创建人';
COMMENT ON COLUMN operation_log.update_by IS '更新人';
COMMENT ON COLUMN operation_log.created_at IS '创建时间';
COMMENT ON COLUMN operation_log.updated_at IS '更新时间';
COMMENT ON COLUMN operation_log.deleted_at IS '删除时间';
COMMENT ON TABLE audit_log IS '审计日志表'; COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON COLUMN audit_log.id IS '主键ID'; COMMENT ON COLUMN audit_log.id IS '主键ID';
COMMENT ON COLUMN audit_log.entity_type IS '实体类型(如User, Role等)'; COMMENT ON COLUMN audit_log.entity_type IS '实体类型(如User, Role等)';
@@ -389,7 +397,6 @@ COMMENT ON COLUMN audit_log.changed_fields IS '变更字段列表';
COMMENT ON COLUMN audit_log.ip_address IS 'IP地址'; COMMENT ON COLUMN audit_log.ip_address IS 'IP地址';
COMMENT ON COLUMN audit_log.description IS '操作描述'; COMMENT ON COLUMN audit_log.description IS '操作描述';
COMMENT ON COLUMN audit_log.created_at IS '记录创建时间'; COMMENT ON COLUMN audit_log.created_at IS '记录创建时间';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表'; COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON COLUMN audit_log_archive.id IS '主键ID'; COMMENT ON COLUMN audit_log_archive.id IS '主键ID';
COMMENT ON COLUMN audit_log_archive.entity_type IS '实体类型(如User, Role等)'; COMMENT ON COLUMN audit_log_archive.entity_type IS '实体类型(如User, Role等)';
@@ -405,3 +412,7 @@ COMMENT ON COLUMN audit_log_archive.user_agent IS '用户代理';
COMMENT ON COLUMN audit_log_archive.description IS '操作描述'; COMMENT ON COLUMN audit_log_archive.description IS '操作描述';
COMMENT ON COLUMN audit_log_archive.created_at IS '记录创建时间'; COMMENT ON COLUMN audit_log_archive.created_at IS '记录创建时间';
COMMENT ON COLUMN audit_log_archive.archived_at IS '归档时间'; COMMENT ON COLUMN audit_log_archive.archived_at IS '归档时间';
COMMENT ON TABLE sys_notice IS '系统公告表';
COMMENT ON TABLE sys_user_message IS '用户消息表';
COMMENT ON TABLE sys_file IS '文件管理表';
COMMENT ON TABLE oauth2_client IS 'OAuth2客户端表';
@@ -0,0 +1,15 @@
-- ============================================
-- V20: 新增轮播图管理菜单按钮权限
-- 注意: sys_menu(id=19, 轮播图管理) 已在 V19 中创建
-- ============================================
-- 轮播图管理按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(191, '轮播图查询', 19, 1, 'F', 'system:banner:query', NULL, 1, NOW(), NOW()),
(192, '轮播图新增', 19, 2, 'F', 'system:banner:add', NULL, 1, NOW(), NOW()),
(193, '轮播图修改', 19, 3, 'F', 'system:banner:edit', NULL, 1, NOW(), NOW()),
(194, '轮播图删除', 19, 4, 'F', 'system:banner:remove', NULL, 1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- 重置菜单序列
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
@@ -0,0 +1,20 @@
-- ============================================
-- V21: 创建轮播图表并更新菜单为顶级菜单
-- ============================================
-- 创建 banner 表
CREATE TABLE IF NOT EXISTS banner (
id BIGSERIAL PRIMARY KEY,
image_url VARCHAR(512) NOT NULL,
title VARCHAR(100) NOT NULL,
subtitle TEXT,
sort_order INT DEFAULT 0,
is_active VARCHAR(1) DEFAULT '1',
link_url VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 将轮播图管理菜单从系统管理子菜单调整为顶级菜单(parent_id=0, sort=8
UPDATE sys_menu SET parent_id = 0, order_num = 8 WHERE id = 19;
@@ -0,0 +1,49 @@
-- ============================================
-- V22: 教练管理 - 角色/权限/菜单
-- ============================================
-- ============================================
-- 1. 教练角色
-- ============================================
INSERT INTO sys_role (id, role_name, role_key, role_sort, status, create_by, update_by, created_at, updated_at)
VALUES (5, '教练', 'coach', 5, 1, 'system', 'system', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
SELECT setval('sys_role_id_seq', COALESCE((SELECT MAX(id) FROM sys_role), 1));
-- ============================================
-- 2. 教练管理权限
-- ============================================
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
('教练查看', 'system:coach:view', '/api/coach', 'GET', '查看教练列表', 1, 'system', 'system', NOW(), NOW()),
('教练创建', 'system:coach:create', '/api/coach', 'POST', '创建教练', 1, 'system', 'system', NOW(), NOW()),
('教练编辑', 'system:coach:edit', '/api/coach', 'PUT', '编辑教练信息', 1, 'system', 'system', NOW(), NOW()),
('教练删除', 'system:coach:delete', '/api/coach', 'DELETE', '删除(禁用)教练', 1, 'system', 'system', NOW(), NOW());
-- ============================================
-- 3. 为超级管理员角色分配教练权限
-- ============================================
INSERT INTO sys_role_permission (role_id, permission_id, create_by, update_by, created_at, updated_at)
SELECT 1, id, 'system', 'system', NOW(), NOW() FROM sys_permission
WHERE permission_code LIKE 'system:coach:%'
AND id NOT IN (SELECT permission_id FROM sys_role_permission WHERE role_id = 1);
-- ============================================
-- 4. 教练管理菜单(顶级菜单)
-- ============================================
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(20, '教练管理', 0, 9, 'C', 'system:coach:list', 'system/coach/index', 1, NOW(), NOW());
-- 教练管理按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(201, '教练查询', 20, 1, 'F', 'system:coach:query', NULL, 1, NOW(), NOW()),
(202, '教练新增', 20, 2, 'F', 'system:coach:add', NULL, 1, NOW(), NOW()),
(203, '教练修改', 20, 3, 'F', 'system:coach:edit', NULL, 1, NOW(), NOW()),
(204, '教练删除', 20, 4, 'F', 'system:coach:remove', NULL, 1, NOW(), NOW());
-- ============================================
-- 5. 重置序列
-- ============================================
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
SELECT setval('sys_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_permission));
SELECT setval('sys_role_permission_id_seq',(SELECT COALESCE(MAX(id), 1) FROM sys_role_permission));
@@ -0,0 +1,30 @@
-- ============================================
-- 教练违规记录表
-- 版本: V23
-- 描述: 创建教练违规记录表
-- ============================================
CREATE TABLE IF NOT EXISTS coach_violation (
id BIGSERIAL PRIMARY KEY,
coach_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
violation_time TIMESTAMP NOT NULL,
violation_reason VARCHAR(50) NOT NULL,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE coach_violation IS '教练违规记录表';
COMMENT ON COLUMN coach_violation.id IS '主键ID';
COMMENT ON COLUMN coach_violation.coach_id IS '教练ID(关联sys_user';
COMMENT ON COLUMN coach_violation.course_id IS '团课ID(关联group_course';
COMMENT ON COLUMN coach_violation.violation_time IS '违规时间';
COMMENT ON COLUMN coach_violation.violation_reason IS '违规原因(COACH_LATE-教练迟到, COACH_ABSENT-教练缺席, NOT_MANUAL_END-教练未手动结课)';
COMMENT ON COLUMN coach_violation.create_by IS '创建人';
COMMENT ON COLUMN coach_violation.update_by IS '更新人';
COMMENT ON COLUMN coach_violation.created_at IS '创建时间';
COMMENT ON COLUMN coach_violation.updated_at IS '更新时间';
COMMENT ON COLUMN coach_violation.deleted_at IS '删除时间(软删除)';
@@ -0,0 +1,82 @@
-- ============================================
-- V24: 教练测试数据
-- 描述: 创建5个教练用户并分配团课,每教练负责一种课型
-- 确保同教练的课程时间不冲突
-- ============================================
-- ============================================
-- 1. 教练用户数据(密码均为 Test@123)
-- ============================================
INSERT INTO sys_user (id, username, password, email, phone, nickname, status, create_by, update_by, created_at, updated_at)
VALUES
(11, 'coach_zhang', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_zhang@novalon.com', '13800138011', '张教练(瑜伽)', 1, 'system', 'system', NOW(), NOW()),
(12, 'coach_li', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_li@novalon.com', '13800138012', '李教练(动感单车)', 1, 'system', 'system', NOW(), NOW()),
(13, 'coach_wang', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_wang@novalon.com', '13800138013', '王教练(HIIT', 1, 'system', 'system', NOW(), NOW()),
(14, 'coach_zhao', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_zhao@novalon.com', '13800138014', '赵教练(普拉提)', 1, 'system', 'system', NOW(), NOW()),
(15, 'coach_chen', '$2a$12$nZ1EMUpZQljbnEdIKzH72eHlDJKUmHmHppnTTVth/SlHs5VpSAr8C', 'coach_chen@novalon.com', '13800138015', '陈教练(搏击操)', 1, 'system', 'system', NOW(), NOW())
ON CONFLICT (username) DO UPDATE SET
password = EXCLUDED.password,
status = EXCLUDED.status;
SELECT setval('sys_user_id_seq', 15);
-- ============================================
-- 2. 分配教练角色(role_id=5
-- ============================================
INSERT INTO user_role (user_id, role_id, created_by, created_at)
VALUES
(11, 5, 'system', NOW()),
(12, 5, 'system', NOW()),
(13, 5, 'system', NOW()),
(14, 5, 'system', NOW()),
(15, 5, 'system', NOW())
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ============================================
-- 3. 为团课分配教练(按课型,无时间冲突)
-- coach_id=11 张教练: 瑜伽 5门
-- coach_id=12 李教练: 动感单车 5门
-- coach_id=13 王教练: HIIT训练 5门
-- coach_id=14 赵教练: 普拉提 5门
-- coach_id=15 陈教练: 搏击操 5门
-- ============================================
-- 瑜伽课程 → 张教练(11)
UPDATE group_course SET coach_id = 11, updated_at = NOW()
WHERE course_type = (SELECT id FROM group_course_type WHERE type_name = '瑜伽')
AND deleted_at IS NULL;
-- 动感单车课程 → 李教练(12)
UPDATE group_course SET coach_id = 12, updated_at = NOW()
WHERE course_type = (SELECT id FROM group_course_type WHERE type_name = '动感单车')
AND deleted_at IS NULL;
-- HIIT训练课程 → 王教练(13)
UPDATE group_course SET coach_id = 13, updated_at = NOW()
WHERE course_type = (SELECT id FROM group_course_type WHERE type_name = 'HIIT训练')
AND deleted_at IS NULL;
-- 普拉提课程 → 赵教练(14)
UPDATE group_course SET coach_id = 14, updated_at = NOW()
WHERE course_type = (SELECT id FROM group_course_type WHERE type_name = '普拉提')
AND deleted_at IS NULL;
-- 搏击操课程 → 陈教练(15)
UPDATE group_course SET coach_id = 15, updated_at = NOW()
WHERE course_type = (SELECT id FROM group_course_type WHERE type_name = '搏击操')
AND deleted_at IS NULL;
-- ============================================
-- 4. 验证:确认每个团课都有教练
-- ============================================
DO $$
DECLARE
null_count INTEGER;
BEGIN
SELECT COUNT(*) INTO null_count FROM group_course WHERE coach_id IS NULL AND deleted_at IS NULL;
IF null_count > 0 THEN
RAISE NOTICE '警告:仍有 % 门团课缺少教练', null_count;
ELSE
RAISE NOTICE '所有团课已成功分配教练';
END IF;
END $$;
@@ -0,0 +1,141 @@
-- ============================================
-- Novalon管理系统索引优化脚本
-- 版本: V2
-- 描述: 为核心系统表创建必要的索引以提升查询性能
-- ============================================
-- ============================================
-- 用户与角色表索引
-- ============================================
-- 用户表索引
CREATE INDEX IF NOT EXISTS idx_users_username ON sys_user(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON sys_user(email);
CREATE INDEX IF NOT EXISTS idx_users_status ON sys_user(status);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON sys_user(deleted_at);
-- 角色表索引
CREATE INDEX IF NOT EXISTS idx_roles_role_key ON sys_role(role_key);
CREATE INDEX IF NOT EXISTS idx_roles_status ON sys_role(status);
CREATE INDEX IF NOT EXISTS idx_roles_deleted_at ON sys_role(deleted_at);
-- 用户角色关联表索引
CREATE INDEX IF NOT EXISTS idx_user_role_user_id ON user_role(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_role_id ON user_role(role_id);
-- ============================================
-- 权限表索引
-- ============================================
-- 权限表索引
CREATE INDEX IF NOT EXISTS idx_permission_code ON sys_permission(permission_code);
CREATE INDEX IF NOT EXISTS idx_permission_resource ON sys_permission(resource);
CREATE INDEX IF NOT EXISTS idx_permission_status ON sys_permission(status);
-- 角色权限关联表索引
CREATE INDEX IF NOT EXISTS idx_role_permission_role_id ON sys_role_permission(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permission_permission_id ON sys_role_permission(permission_id);
-- ============================================
-- 菜单表索引
-- ============================================
CREATE INDEX IF NOT EXISTS idx_sys_menu_parent_id ON sys_menu(parent_id);
CREATE INDEX IF NOT EXISTS idx_sys_menu_status ON sys_menu(status);
CREATE INDEX IF NOT EXISTS idx_sys_menu_deleted_at ON sys_menu(deleted_at);
-- ============================================
-- 字典表索引
-- ============================================
-- 字典类型表索引
CREATE INDEX IF NOT EXISTS idx_sys_dict_type_dict_type ON sys_dict_type(dict_type);
CREATE INDEX IF NOT EXISTS idx_sys_dict_type_status ON sys_dict_type(status);
CREATE INDEX IF NOT EXISTS idx_sys_dict_type_deleted_at ON sys_dict_type(deleted_at);
-- 字典数据表索引
CREATE INDEX IF NOT EXISTS idx_sys_dict_data_dict_type ON sys_dict_data(dict_type);
CREATE INDEX IF NOT EXISTS idx_sys_dict_data_dict_value ON sys_dict_data(dict_value);
CREATE INDEX IF NOT EXISTS idx_sys_dict_data_status ON sys_dict_data(status);
CREATE INDEX IF NOT EXISTS idx_sys_dict_data_deleted_at ON sys_dict_data(deleted_at);
-- 通用字典表索引
CREATE INDEX IF NOT EXISTS idx_sys_dictionary_type ON sys_dictionary(type);
CREATE INDEX IF NOT EXISTS idx_sys_dictionary_type_code ON sys_dictionary(type, code);
CREATE INDEX IF NOT EXISTS idx_sys_dictionary_deleted_at ON sys_dictionary(deleted_at);
-- ============================================
-- 系统配置表索引
-- ============================================
CREATE INDEX IF NOT EXISTS idx_sys_config_config_key ON sys_config(config_key);
CREATE INDEX IF NOT EXISTS idx_sys_config_config_type ON sys_config(config_type);
CREATE INDEX IF NOT EXISTS idx_sys_config_deleted_at ON sys_config(deleted_at);
-- ============================================
-- 日志表索引
-- ============================================
-- 登录日志表索引
CREATE INDEX IF NOT EXISTS idx_sys_login_log_username ON sys_login_log(username);
CREATE INDEX IF NOT EXISTS idx_sys_login_log_ip ON sys_login_log(ip);
CREATE INDEX IF NOT EXISTS idx_sys_login_log_status ON sys_login_log(status);
CREATE INDEX IF NOT EXISTS idx_sys_login_log_login_time ON sys_login_log(login_time);
-- 异常日志表索引
CREATE INDEX IF NOT EXISTS idx_sys_exception_log_username ON sys_exception_log(username);
CREATE INDEX IF NOT EXISTS idx_sys_exception_log_exception_name ON sys_exception_log(exception_name);
CREATE INDEX IF NOT EXISTS idx_sys_exception_log_create_time ON sys_exception_log(create_time);
-- 操作日志表索引
CREATE INDEX IF NOT EXISTS idx_operation_log_username ON operation_log(username);
CREATE INDEX IF NOT EXISTS idx_operation_log_operation ON operation_log(operation);
CREATE INDEX IF NOT EXISTS idx_operation_log_created_at ON operation_log(created_at);
CREATE INDEX IF NOT EXISTS idx_operation_log_status ON operation_log(status);
CREATE INDEX IF NOT EXISTS idx_operation_log_deleted_at ON operation_log(deleted_at);
-- 审计日志表索引
CREATE INDEX IF NOT EXISTS idx_audit_log_entity_type ON audit_log(entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity_id ON audit_log(entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_operation_type ON audit_log(operation_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_operator ON audit_log(operator);
CREATE INDEX IF NOT EXISTS idx_audit_log_operation_time ON audit_log(operation_time);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id);
-- 审计日志归档表索引
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_entity_type ON audit_log_archive(entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_entity_id ON audit_log_archive(entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_operation_type ON audit_log_archive(operation_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_operator ON audit_log_archive(operator);
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_operation_time ON audit_log_archive(operation_time);
CREATE INDEX IF NOT EXISTS idx_audit_log_archive_archived_at ON audit_log_archive(archived_at);
-- ============================================
-- 通知与消息表索引
-- ============================================
-- 系统公告表索引
CREATE INDEX IF NOT EXISTS idx_sys_notice_notice_type ON sys_notice(notice_type);
CREATE INDEX IF NOT EXISTS idx_sys_notice_status ON sys_notice(status);
CREATE INDEX IF NOT EXISTS idx_sys_notice_deleted_at ON sys_notice(deleted_at);
-- 用户消息表索引
CREATE INDEX IF NOT EXISTS idx_sys_user_message_user_id ON sys_user_message(user_id);
CREATE INDEX IF NOT EXISTS idx_sys_user_message_notice_id ON sys_user_message(notice_id);
CREATE INDEX IF NOT EXISTS idx_sys_user_message_is_read ON sys_user_message(is_read);
CREATE INDEX IF NOT EXISTS idx_sys_user_message_deleted_at ON sys_user_message(deleted_at);
-- ============================================
-- 文件管理表索引
-- ============================================
CREATE INDEX IF NOT EXISTS idx_sys_file_file_type ON sys_file(file_type);
CREATE INDEX IF NOT EXISTS idx_sys_file_deleted_at ON sys_file(deleted_at);
-- ============================================
-- OAuth2客户端表索引
-- ============================================
CREATE INDEX IF NOT EXISTS idx_oauth2_client_client_id ON oauth2_client(client_id);
CREATE INDEX IF NOT EXISTS idx_oauth2_client_enabled ON oauth2_client(enabled);
CREATE INDEX IF NOT EXISTS idx_oauth2_client_deleted_at ON oauth2_client(deleted_at);
@@ -0,0 +1,15 @@
-- ============================================
-- Novalon管理系统权限授予脚本
-- 版本: V3
-- 描述: 为novalon用户授予所有表的访问权限
-- ============================================
-- 授予所有表的SELECT, INSERT, UPDATE, DELETE权限
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO novalon;
-- 授予所有序列的使用权限
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO novalon;
-- 设置默认权限,使未来创建的表自动授予novalon用户权限
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO novalon;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO novalon;

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