diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java index aab2e18..1e3d0d1 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java @@ -33,10 +33,6 @@ public interface GroupCourseDao extends R2dbcRepository @Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") Mono 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 updateCurrentMembers(Long id, Integer delta, LocalDateTime updatedAt); - @Modifying @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") Mono softDelete(Long id, LocalDateTime deletedAt); @@ -47,46 +43,64 @@ public interface GroupCourseDao extends R2dbcRepository Flux findByCourseTypeAndDeletedAtIsNull(Long courseType); + // ==================== 教练相关查询 ==================== + + Flux findByCoachIdAndDeletedAtIsNull(Long coachId); + + Flux findByCoachIdAndDeletedAtIsNull(Long coachId, Sort sort); + + @Query("SELECT COUNT(*) FROM group_course WHERE coach_id = :coachId AND status = :status AND deleted_at IS NULL") + Mono 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 cancelCoursesByCoachIdExceptStatus(Long coachId, String excludeStatus, LocalDateTime updatedAt); + /** * 多条件查询团课(使用 DatabaseClient 构建动态 SQL) */ default Flux 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 conditions = new ArrayList<>(); // 默认不查询可预约团课(status = '0' 且未过期) - conditions.add("status = '0'"); - conditions.add("end_time > NOW()"); + conditions.add("gc.status = '0'"); + conditions.add("gc.end_time > NOW()"); // 1. 团课名称模糊查询 if (query.getCourseName() != null && !query.getCourseName().isEmpty()) { - conditions.add("course_name ILIKE :courseName"); + conditions.add("gc.course_name ILIKE :courseName"); } // 2. 基于团课类型查询 if (query.getCourseType() != null) { - conditions.add("course_type = :courseType"); + conditions.add("gc.course_type = :courseType"); } // 3. 基于日期时间段查询 if (query.getStartDate() != null) { - conditions.add("start_time >= :startDate"); + conditions.add("gc.start_time >= :startDate"); } if (query.getEndDate() != null) { - conditions.add("start_time <= :endDate"); + conditions.add("gc.start_time <= :endDate"); } // 4. 基于早晨/下午/夜晚时间段查询 if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) { switch (query.getTimePeriod().toLowerCase()) { 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; 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; 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; default: break; @@ -104,20 +118,20 @@ public interface GroupCourseDao extends R2dbcRepository List orderClauses = new ArrayList<>(); if (hasRemainingMost) { - orderClauses.add(" (max_members - current_members) DESC"); + orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC"); } if (hasPriceSort) { if ("asc".equalsIgnoreCase(query.getPriceSort())) { - orderClauses.add(" stored_value_amount ASC"); + orderClauses.add(" gc.stored_value_amount ASC"); } else if ("desc".equalsIgnoreCase(query.getPriceSort())) { - orderClauses.add(" stored_value_amount DESC"); + orderClauses.add(" gc.stored_value_amount DESC"); } } sql.append(String.join(",", orderClauses)); } else { - sql.append(" ORDER BY start_time ASC"); + sql.append(" ORDER BY gc.start_time ASC"); } // 分页 @@ -145,30 +159,7 @@ public interface GroupCourseDao extends R2dbcRepository spec = spec.bind("limit", size); spec = spec.bind("offset", offset); - return spec.map((row, meta) -> { - GroupCourseEntity entity = new GroupCourseEntity(); - entity.setId(row.get("id", Long.class)); - entity.setCourseName(row.get("course_name", String.class)); - entity.setCoachId(row.get("coach_id", Long.class)); - entity.setCourseType(row.get("course_type", Long.class)); - entity.setStartTime(row.get("start_time", LocalDateTime.class)); - entity.setEndTime(row.get("end_time", LocalDateTime.class)); - entity.setMaxMembers(row.get("max_members", Integer.class)); - entity.setCurrentMembers(row.get("current_members", Integer.class)); - String statusStr = row.get("status", String.class); - entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null); - entity.setLocation(row.get("location", String.class)); - entity.setCoverImage(row.get("cover_image", String.class)); - entity.setDescription(row.get("description", String.class)); - entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class)); - entity.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; - }).all(); + return spec.map((row, meta) -> mapRowToEntity(row)).all(); } /** @@ -233,14 +224,19 @@ public interface GroupCourseDao extends R2dbcRepository * 分页查询团课(支持 keyword 和 status 过滤) */ default Flux findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) { - 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 conditions = new ArrayList<>(); if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) { - conditions.add("course_name ILIKE :keyword"); + conditions.add("gc.course_name ILIKE :keyword"); } if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) { - conditions.add("status = :status"); + conditions.add("gc.status = :status"); } if (!conditions.isEmpty()) { @@ -249,7 +245,7 @@ public interface GroupCourseDao extends R2dbcRepository String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id"; String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC"; - sql.append(" ORDER BY ").append(sort).append(" ").append(order); + sql.append(" ORDER BY gc.").append(sort).append(" ").append(order); int size = pageRequest.getSize(); if (size < 1) size = 10; @@ -268,30 +264,7 @@ public interface GroupCourseDao extends R2dbcRepository spec = spec.bind("limit", size); spec = spec.bind("offset", offset); - return spec.map((row, meta) -> { - GroupCourseEntity entity = new GroupCourseEntity(); - entity.setId(row.get("id", Long.class)); - entity.setCourseName(row.get("course_name", String.class)); - entity.setCoachId(row.get("coach_id", Long.class)); - entity.setCourseType(row.get("course_type", Long.class)); - entity.setStartTime(row.get("start_time", LocalDateTime.class)); - entity.setEndTime(row.get("end_time", LocalDateTime.class)); - entity.setMaxMembers(row.get("max_members", Integer.class)); - entity.setCurrentMembers(row.get("current_members", Integer.class)); - String statusStr = row.get("status", String.class); - entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null); - entity.setLocation(row.get("location", String.class)); - entity.setCoverImage(row.get("cover_image", String.class)); - entity.setDescription(row.get("description", String.class)); - entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class)); - entity.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; - }).all(); + return spec.map((row, meta) -> mapRowToEntity(row)).all(); } /** @@ -323,4 +296,44 @@ public interface GroupCourseDao extends R2dbcRepository return spec.map((row, meta) -> row.get(0, Long.class)).one(); } + + /** + * 统计单门课程的有效预约人数(status='0') + */ + default Mono 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.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; + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseDetail.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseDetail.java index bd693c9..784547c 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseDetail.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourseDetail.java @@ -21,6 +21,9 @@ public class GroupCourseDetail extends BaseDomain { @Schema(description = "教练ID", example = "1") private Long coachId; + @Schema(description = "教练名称", example = "张教练") + private String coachName; + @Schema(description = "课程类型ID", example = "1") private Long courseType; @@ -99,6 +102,14 @@ public class GroupCourseDetail extends BaseDomain { this.coachId = coachId; } + public String getCoachName() { + return coachName; + } + + public void setCoachName(String coachName) { + this.coachName = coachName; + } + public Long getCourseType() { return courseType; } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BookingSagaHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BookingSagaHandler.java index 582b497..7ad7e27 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BookingSagaHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BookingSagaHandler.java @@ -2,8 +2,6 @@ package cn.novalon.gym.manage.groupcourse.handler; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; 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.MemberCardRecord; import cn.novalon.gym.manage.member.enums.MemberCardType; @@ -34,11 +32,9 @@ import java.util.List; public class BookingSagaHandler { private final IGroupCourseBookingRepository bookingRepository; - private final IGroupCourseRepository courseRepository; private final IMemberCardRecordService memberCardRecordService; private final IMemberStoredCardService memberStoredCardService; private final MemberCardRepository memberCardRepository; - private final GroupCourseRedisService redisService; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; @@ -72,24 +68,6 @@ public class BookingSagaHandler { steps.add(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) .then(Mono.just(booking)); } @@ -225,24 +203,6 @@ public class BookingSagaHandler { steps.add(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) .then(bookingRepository.findById(bookingId)); } @@ -301,22 +261,6 @@ public class BookingSagaHandler { }); } - private Mono incrementCourseCurrentMembers(Long courseId) { - return courseRepository.updateCurrentMembers(courseId, 1).then(); - } - - private Mono decrementCourseCurrentMembers(Long courseId) { - return courseRepository.updateCurrentMembers(courseId, -1).then(); - } - - private Mono incrementRedisBookingCount(Long courseId) { - return redisService.incrementBookingCount(courseId).then(); - } - - private Mono decrementRedisBookingCount(Long courseId) { - return redisService.decrementBookingCount(courseId).then(); - } - private Mono executeSaga(List steps, List rollbackSteps) { List completedSteps = new ArrayList<>(); return executeStep(steps, 0, completedSteps); diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java index 9b92588..6bbd6a9 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java @@ -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.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; +import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Validator; +import org.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.HashMap; +import java.util.List; import java.util.Map; +import java.time.LocalDateTime; @Component @Tag(name="团课管理",description = "团课相关操作") public class GroupCourseHandler { + private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class); private final IGroupCourseService groupCourseService; private final Validator validator; private final RedisUtil redisUtil; @@ -37,11 +43,12 @@ public class GroupCourseHandler { this.objectMapper = objectMapper; } - @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表") + @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表(含教练昵称)") public Mono getAllGroupCourse(ServerRequest request){ boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false")); - return ServerResponse.ok() - .body(groupCourseService.findAll(includeDeleted), GroupCourse.class); + return groupCourseService.findAllAsVO(includeDeleted) + .collectList() + .flatMap(list -> ServerResponse.ok().bodyValue(list)); } @Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表") @@ -65,7 +72,7 @@ public class GroupCourseHandler { pageRequest.setOrder("asc"); } - return groupCourseService.findByPage(pageRequest, includeDeleted) + return groupCourseService.findByPageAsVO(pageRequest, includeDeleted) .flatMap(response -> ServerResponse.ok().bodyValue(response)); }); } @@ -289,4 +296,50 @@ public class GroupCourseHandler { }); }); } + + @Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课") + public Mono checkCoachConflict(ServerRequest request) { + return request.bodyToMono(Map.class) + .flatMap(body -> { + if (body == null) { + Map 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 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 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 response = new HashMap<>(); + response.put("success", false); + response.put("message", "检查失败: " + error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseRepository.java index c7a3053..cac770f 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseRepository.java @@ -27,9 +27,14 @@ public interface IGroupCourseRepository { Mono deleteById(Long id); - Mono updateCurrentMembers(Long id, Integer delta); - Flux findByCourseType(Long courseType); Mono> searchGroupCourses(GroupCourseQueryDto query); + + // ==================== 教练相关 ==================== + + Flux findByCoachId(Long coachId); + Flux findByCoachId(Long coachId, Sort sort); + Mono countByCoachIdAndStatus(Long coachId, Long status); + Mono cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus); } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java index 67729aa..f81c9cc 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java @@ -119,7 +119,6 @@ public class GroupCourseRepository implements IGroupCourseRepository { entity.setCreatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now()); entity.setStatus(0L); - entity.setCurrentMembers(0); return groupCourseDao.save(entity) .map(groupCourseConverter::toDomain); @@ -151,17 +150,6 @@ public class GroupCourseRepository implements IGroupCourseRepository { .then(); } - @Override - public Mono updateCurrentMembers(Long id, Integer delta) { - return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now()) - .flatMap(updated -> { - if (updated > 0) { - return findByIdAndDeletedAtIsNull(id); - } - return Mono.empty(); - }); - } - @Override public Flux findByCourseType(Long courseType) { return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType) @@ -190,4 +178,28 @@ public class GroupCourseRepository implements IGroupCourseRepository { }); }); } + + // ==================== 教练相关 ==================== + + @Override + public Flux findByCoachId(Long coachId) { + return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId) + .map(groupCourseConverter::toDomain); + } + + @Override + public Flux findByCoachId(Long coachId, Sort sort) { + return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId, sort) + .map(groupCourseConverter::toDomain); + } + + @Override + public Mono countByCoachIdAndStatus(Long coachId, Long status) { + return groupCourseDao.countByCoachIdAndStatus(coachId, String.valueOf(status)); + } + + @Override + public Mono cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus) { + return groupCourseDao.cancelCoursesByCoachIdExceptStatus(coachId, String.valueOf(excludeStatus), LocalDateTime.now()); + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseService.java index d50826b..d2c802a 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseService.java @@ -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.GroupCourseDetail; 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.Mono; +import java.time.LocalDateTime; +import java.util.List; + public interface IGroupCourseService { Mono findById(Long id); Mono findDetailById(Long id); Flux findAll(); Flux findAll(boolean includeDeleted); + Flux findAllAsVO(boolean includeDeleted); + Mono> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted); Mono> findByPage(PageRequest pageRequest, boolean includeDeleted); @@ -28,4 +34,14 @@ public interface IGroupCourseService { Mono delete(Long id); Mono> searchGroupCourses(GroupCourseQueryDto query); + + /** + * 检查教练在指定时间段内是否有时间冲突的团课 + * @param coachId 教练ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param excludeCourseId 排除的课程ID(编辑时排除自身) + * @return 冲突的课程列表 + */ + Mono> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId); } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java index 6eb5bbe..c7d9c9f 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java @@ -72,8 +72,16 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { return Mono.error(new RuntimeException("系统繁忙,请稍后重试")); } - // 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取 + // 2. 从缓存或数据库获取课程信息,并用实时预约数覆盖currentMembers return getCourseWithCache(courseId) + .flatMap(course -> + bookingRepository.countValidBookings(courseId) + .map(count -> { + course.setCurrentMembers(count.intValue()); + return course; + }) + .defaultIfEmpty(course) + ) .flatMap(course -> { // 3. 验证课程状态 Long courseStatus = course.getStatus(); @@ -150,35 +158,20 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { if (saved.getId() == null) { return Mono.error(new RuntimeException("保存预约记录失败")); } - // 10. 更新课程当前人数 - return courseRepository.updateCurrentMembers(courseId, 1) - .flatMap(updatedCourse -> { - // 11. 更新Redis预约计数 - return redisService.incrementBookingCount(courseId) - .flatMap(count -> { - // 12. 释放锁 - return redisService.releaseLock(courseId, requestId) - .then(Mono.just(saved)); - }); - }) - .doOnSuccess(savedBooking -> { - logger.info("预约成功:bookingId={}, courseId={}, memberId={}", - saved.getId(), courseId, memberId); - // 发布预约成功事件 - bookingReminderEventPublisher.publishBookingSuccessEvent( - saved.getId(), - saved.getMemberId(), - saved.getCourseName(), - saved.getCourseStartTime().toString() - ); - }) - .doOnError(error -> { - // 失败时回滚Redis计数和课程人数 - redisService.decrementBookingCount(courseId).subscribe(); - courseRepository.updateCurrentMembers(courseId, -1).subscribe(); - logger.error("预约失败:courseId={}, memberId={}, error={}", - courseId, memberId, error.getMessage()); - }); + // 10. 释放锁 + return redisService.releaseLock(courseId, requestId) + .then(Mono.just(saved)); + }) + .doOnSuccess(savedBooking -> { + logger.info("预约成功:bookingId={}, courseId={}, memberId={}", + savedBooking.getId(), courseId, memberId); + // 发布预约成功事件 + bookingReminderEventPublisher.publishBookingSuccessEvent( + savedBooking.getId(), + savedBooking.getMemberId(), + savedBooking.getCourseName(), + savedBooking.getCourseStartTime().toString() + ); }); }) ); @@ -273,30 +266,22 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { if (rows == 0) { return Mono.error(new RuntimeException("更新预约状态失败")); } - // 6. 减少课程当前人数 - return courseRepository.updateCurrentMembers(booking.getCourseId(), -1) - .flatMap(updatedCourse -> { - // 7. 更新Redis预约计数 - return redisService.decrementBookingCount(booking.getCourseId()) - .flatMap(count -> { - // 8. 释放锁 - return redisService.releaseLock(bookingId, requestId) - .then(bookingRepository.findById(bookingId)); - }); - }) - .doOnSuccess(updatedBooking -> { - logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); - // 发布预约取消事件 - bookingReminderEventPublisher.publishBookingCancelEvent( - updatedBooking.getId(), - updatedBooking.getMemberId(), - updatedBooking.getCourseName() - ); - }) - .doOnError(error -> { - logger.error("取消预约失败:bookingId={}, memberId={}, error={}", - bookingId, memberId, error.getMessage()); - }); + // 6. 释放锁 + return redisService.releaseLock(bookingId, requestId) + .then(bookingRepository.findById(bookingId)); + }) + .doOnSuccess(updatedBooking -> { + logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); + // 发布预约取消事件 + bookingReminderEventPublisher.publishBookingCancelEvent( + updatedBooking.getId(), + updatedBooking.getMemberId(), + updatedBooking.getCourseName() + ); + }) + .doOnError(error -> { + logger.error("取消预约失败:bookingId={}, memberId={}, error={}", + bookingId, memberId, error.getMessage()); }); }) .onErrorResume(error -> { diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java index f8110e4..1e7a6df 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseRedisService.java @@ -149,46 +149,4 @@ public class GroupCourseRedisService { }) .defaultIfEmpty(false); } - - /** - * 获取课程预约人数(缓存) - */ - public Mono getBookingCount(Long courseId) { - String key = "booking_count:" + courseId; - return reactiveRedisTemplate.opsForValue() - .get(key) - .map(obj -> (Integer) obj) - .defaultIfEmpty(0); - } - - /** - * 增加课程预约人数 - */ - public Mono incrementBookingCount(Long courseId) { - String key = "booking_count:" + courseId; - return reactiveRedisTemplate.opsForValue() - .increment(key) - .doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count)); - } - - /** - * 减少课程预约人数 - */ - public Mono decrementBookingCount(Long courseId) { - String key = "booking_count:" + courseId; - return reactiveRedisTemplate.opsForValue() - .decrement(key) - .doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count)); - } - - /** - * 设置课程预约人数(用于从数据库同步) - */ - public Mono 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(); - } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java index aab1597..c0d6f04 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java @@ -19,12 +19,15 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; 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.MemberCardRecord; import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.repository.MemberCardRepository; 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.databind.ObjectMapper; import org.slf4j.Logger; @@ -35,7 +38,9 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.LocalDateTime; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; @Service @@ -53,6 +58,7 @@ public class GroupCourseService implements IGroupCourseService { private final GroupCourseStateMachine stateMachine; 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_ID_PREFIX = "group_course:id:"; @@ -71,7 +77,8 @@ public class GroupCourseService implements IGroupCourseService { ObjectMapper objectMapper, GroupCourseStateMachine stateMachine, DatabaseClient databaseClient, - ISysFileService fileService){ + ISysFileService fileService, + ISysUserRepository sysUserRepository){ this.groupCourseRepository = groupCourseRepository; this.bookingRepository = bookingRepository; this.groupCourseTypeRepository = groupCourseTypeRepository; @@ -83,6 +90,7 @@ public class GroupCourseService implements IGroupCourseService { this.stateMachine = stateMachine; this.databaseClient = databaseClient; this.fileService = fileService; + this.sysUserRepository = sysUserRepository; } @Override @@ -128,6 +136,8 @@ public class GroupCourseService implements IGroupCourseService { }) .switchIfEmpty(Mono.just(buildDetail(course, null))); }) + .flatMap(this::enrichCoachName) + .flatMap(this::enrichCurrentMembers) .flatMap(detail -> { try { String jsonData = objectMapper.writeValueAsString(detail); @@ -173,6 +183,52 @@ public class GroupCourseService implements IGroupCourseService { return detail; } + /** + * 为团课详情对象填充教练名称 + */ + private Mono 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 enrichCurrentMembers(GroupCourseDetail detail) { + return bookingRepository.countValidBookings(detail.getId()) + .map(count -> { + detail.setCurrentMembers(count.intValue()); + return detail; + }) + .defaultIfEmpty(detail); + } + + /** + * 为团课对象填充实时预约人数 + */ + private Mono enrichCurrentMembers(GroupCourse course) { + return bookingRepository.countValidBookings(course.getId()) + .map(count -> { + course.setCurrentMembers(count.intValue()); + return course; + }) + .defaultIfEmpty(course); + } + @Override public Mono findById(Long id) { String cacheKey = CACHE_KEY_ID_PREFIX + id; @@ -206,7 +262,8 @@ public class GroupCourseService implements IGroupCourseService { } }) .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id)) - ); + ) + .flatMap(this::enrichCurrentMembers); } @Override @@ -223,6 +280,47 @@ public class GroupCourseService implements IGroupCourseService { } } + @Override + public Flux 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 public Mono> findByPage(PageRequest pageRequest, boolean includeDeleted) { int page = pageRequest.getPage(); @@ -275,6 +373,73 @@ public class GroupCourseService implements IGroupCourseService { ); } + @Override + public Mono> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted) { + return findByPage(pageRequest, includeDeleted) + .flatMap(pageResponse -> { + List courses = pageResponse.getContent(); + if (courses.isEmpty()) { + PageResponse 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 voList = enrichedCourses.stream() + .map(c -> GroupCourseVO.from(c, null)) + .toList(); + PageResponse 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 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 public Mono create(GroupCourse groupCourse) { return groupCourseRepository.save(groupCourse) @@ -491,6 +656,15 @@ public class GroupCourseService implements IGroupCourseService { public Mono signIn(Long courseId, Long memberId) { return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除"))) + .flatMap(course -> + // 用实时预约人数覆盖currentMembers + bookingRepository.countValidBookings(courseId) + .map(count -> { + course.setCurrentMembers(count.intValue()); + return course; + }) + .defaultIfEmpty(course) + ) .flatMap(course -> { // 校验1:团课已取消 if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { @@ -521,11 +695,9 @@ public class GroupCourseService implements IGroupCourseService { 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); - }); + // 更新预约状态为已出席(2),不再手动计数 + return bookingRepository.updateStatus(booking.getId(), "2") + .thenReturn(course); }); }) .doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId)) @@ -571,4 +743,33 @@ public class GroupCourseService implements IGroupCourseService { .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")) .then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then(); } + + @Override + public Mono> 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()); + } + }); + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/vo/GroupCourseVO.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/vo/GroupCourseVO.java new file mode 100644 index 0000000..c76330b --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/vo/GroupCourseVO.java @@ -0,0 +1,124 @@ +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 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.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 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; } +} diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index a61484f..d8263ab 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -20,6 +20,7 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler; +import cn.novalon.gym.manage.app.handler.CoachHandler; 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.config.SysConfigHandler; @@ -84,7 +85,8 @@ public class SystemRouter { CheckInHandler checkInHandler, DataStatisticsHandler dataStatisticsHandler, PhoneAuthHandler phoneAuthHandler, - PaymentHandler paymentHandler) { + PaymentHandler paymentHandler, + CoachHandler coachHandler) { return route() // ========== 诊断路由 ========== @@ -118,6 +120,13 @@ public class SystemRouter { .GET("/api/users/{id}/roles", userHandler::getUserRoles) .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/menus", menuHandler::getAllMenus) .GET("/api/menus/tree", menuHandler::getMenuTree) @@ -362,6 +371,7 @@ public class SystemRouter { .POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn) .POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses) + .POST("/api/groupCourse/check-coach-conflict", groupCourseHandler::checkCoachConflict) // ========= 签到模块路由 ========== // ===== 签到核心功能 ===== diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/handler/CoachHandler.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/handler/CoachHandler.java new file mode 100644 index 0000000..dddd477 --- /dev/null +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/handler/CoachHandler.java @@ -0,0 +1,102 @@ +package cn.novalon.gym.manage.app.handler; + +import cn.novalon.gym.manage.app.service.CoachService; +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 中避免模块循环依赖) + * + * @author 张翔 + * @date 2026-07-19 + */ +@Component +@Tag(name = "教练管理", description = "教练相关操作") +public class CoachHandler { + + private static final Logger logger = LoggerFactory.getLogger(CoachHandler.class); + private final CoachService coachService; + private final Validator validator; + + public CoachHandler(CoachService coachService, Validator validator) { + this.coachService = coachService; + this.validator = validator; + } + + @Operation(summary = "获取所有教练", description = "获取系统中所有教练列表") + public Mono getAllCoaches(ServerRequest request) { + return ServerResponse.ok() + .body(coachService.getAllCoaches(), SysUser.class); + } + + @Operation(summary = "创建教练", description = "创建新教练(创建用户并分配教练角色)") + public Mono createCoach(ServerRequest request) { + return request.bodyToMono(CoachCreateRequest.class) + .flatMap(req -> { + var violations = validator.validate(req); + if (!violations.isEmpty()) { + Map errors = new HashMap<>(); + violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage())); + return ServerResponse.badRequest().bodyValue(errors); + } + return coachService.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 updateCoach(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(CoachUpdateRequest.class) + .flatMap(req -> coachService.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 disableCoach(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return coachService.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 getCoachCourses(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return coachService.getCoachCourses(id) + .collectList() + .flatMap(courses -> ServerResponse.ok().bodyValue(courses)) + .switchIfEmpty(ServerResponse.ok().bodyValue(List.of())); + } +} diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/service/CoachService.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/service/CoachService.java new file mode 100644 index 0000000..91bb635 --- /dev/null +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/service/CoachService.java @@ -0,0 +1,144 @@ +package cn.novalon.gym.manage.app.service; + +import cn.novalon.gym.manage.common.util.StatusConstants; +import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; +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.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.LocalDateTime; + +/** + * 教练服务(跨模块编排,放在 manage-app 中避免模块循环依赖) + * + * @author 张翔 + * @date 2026-07-19 + */ +@Service +public class CoachService { + + private static final Logger logger = LoggerFactory.getLogger(CoachService.class); + private static final String COACH_ROLE_NAME = "教练"; + + private final ISysUserRepository userRepository; + private final ISysRoleRepository roleRepository; + private final IUserRoleRepository userRoleRepository; + private final IGroupCourseRepository groupCourseRepository; + private final IGroupCourseBookingRepository bookingRepository; + private final PasswordEncoder passwordEncoder; + + public CoachService(ISysUserRepository userRepository, + ISysRoleRepository roleRepository, + IUserRoleRepository userRoleRepository, + IGroupCourseRepository groupCourseRepository, + IGroupCourseBookingRepository bookingRepository, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.userRoleRepository = userRoleRepository; + this.groupCourseRepository = groupCourseRepository; + this.bookingRepository = bookingRepository; + this.passwordEncoder = passwordEncoder; + } + + public Flux 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 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 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 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(); + })) + ); + } + + public Flux 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 getCoachRoleId() { + return roleRepository.findByRoleName(COACH_ROLE_NAME) + .map(SysRole::getId) + .switchIfEmpty(Mono.error(new RuntimeException("教练角色未找到,请先执行数据库迁移"))); + } +} diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V20__Add_banner_menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V20__Add_banner_menu.sql index 10465f5..3e40add 100644 --- a/gym-manage-api/manage-db/src/main/resources/db/migration/V20__Add_banner_menu.sql +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V20__Add_banner_menu.sql @@ -1,18 +1,15 @@ -- ============================================ --- V20: 新增轮播图管理菜单 --- 描述: 为首页轮播图管理功能创建菜单项 +-- 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 -(19, '轮播图管理', 0, 8, 'C', 'system:banner:list', 'banner/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 (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()); +(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)); diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V22__Add_coach_role_menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V22__Add_coach_role_menu.sql new file mode 100644 index 0000000..e9bca68 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V22__Add_coach_role_menu.sql @@ -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)); diff --git a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachCreateRequest.java b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachCreateRequest.java new file mode 100644 index 0000000..c884fbc --- /dev/null +++ b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachCreateRequest.java @@ -0,0 +1,54 @@ +package cn.novalon.gym.manage.sys.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 教练创建请求DTO + * + * @author 张翔 + * @date 2026-07-19 + */ +@Schema(description = "教练创建请求") +public class CoachCreateRequest { + + @Schema(description = "用户名", example = "coach01") + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 50, message = "用户名长度必须在3-50之间") + @Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "用户名只能包含字母、数字、下划线和横线") + private String username; + + @Schema(description = "昵称", example = "张教练") + @Size(max = 100, message = "昵称长度不能超过100") + private String nickname; + + @Schema(description = "密码", example = "Coach@123") + @NotBlank(message = "密码不能为空") + @Size(min = 8, max = 20, message = "密码长度必须在8-20之间") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", message = "密码必须包含大小写字母和数字") + private String password; + + @Schema(description = "邮箱", example = "coach@example.com") + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @Schema(description = "手机号", example = "13800138001") + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } +} diff --git a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachUpdateRequest.java b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachUpdateRequest.java new file mode 100644 index 0000000..59c078f --- /dev/null +++ b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/dto/request/CoachUpdateRequest.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.sys.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 教练更新请求DTO + * + * @author 张翔 + * @date 2026-07-19 + */ +@Schema(description = "教练更新请求") +public class CoachUpdateRequest { + + @Schema(description = "昵称", example = "张教练") + @Size(max = 100, message = "昵称长度不能超过100") + private String nickname; + + @Schema(description = "邮箱", example = "coach@example.com") + @Email(message = "邮箱格式不正确") + private String email; + + @Schema(description = "手机号", example = "13800138001") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } +} diff --git a/gym-manage-uniapp/pages/course-detail/course-detail.vue b/gym-manage-uniapp/pages/course-detail/course-detail.vue index 82654db..72c8f65 100644 --- a/gym-manage-uniapp/pages/course-detail/course-detail.vue +++ b/gym-manage-uniapp/pages/course-detail/course-detail.vue @@ -294,10 +294,16 @@ goBack() { uni.navigateBack() }, async handleBooking() { if (!this.course.canBook || this.submitting) return + if (!store.isLoggedIn) { + uni.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) }, 1000) + return + } const memberInfo = store.getMemberInfo() const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null if (!memberId) { uni.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) }, 1000) return } this.submitting = true diff --git a/gym-manage-uniapp/store/index.js b/gym-manage-uniapp/store/index.js index d27b3a0..6a508a8 100644 --- a/gym-manage-uniapp/store/index.js +++ b/gym-manage-uniapp/store/index.js @@ -42,10 +42,11 @@ const store = { } }, - /** 更新会员信息 */ + /** 更新会员信息(合并而非覆盖,保留 memberId 等关键字段) */ updateMemberInfo(memberInfo) { try { - uni.setStorageSync(MEMBER_KEY, memberInfo) + const existing = uni.getStorageSync(MEMBER_KEY) || {} + uni.setStorageSync(MEMBER_KEY, { ...existing, ...memberInfo }) } catch (e) { console.error('更新会员信息失败:', e) } diff --git a/gym-manage-web/src/api/coach.api.ts b/gym-manage-web/src/api/coach.api.ts new file mode 100644 index 0000000..865f78a --- /dev/null +++ b/gym-manage-web/src/api/coach.api.ts @@ -0,0 +1,50 @@ +import request from '@/utils/request' +export { type GroupCourse } from '@/api/groupCourse.api' + +export interface Coach { + id: string + username: string + nickname: string + email: string + phone: string + avatar: string + status: number + createdAt: string + updatedAt: string +} + +export interface CoachCreateRequest { + username: string + password: string + nickname: string + email: string + phone: string +} + +export interface CoachUpdateRequest { + nickname?: string + email?: string + phone?: string +} + +export const coachApi = { + /** 获取所有教练列表 */ + getAll: () => + request.get('/coach/list'), + + /** 创建教练(自动分配教练角色) */ + create: (data: CoachCreateRequest) => + request.post('/coach', data), + + /** 更新教练信息 */ + update: (id: string, data: CoachUpdateRequest) => + request.put(`/coach/${id}`, data), + + /** 禁用教练 */ + disable: (id: string) => + request.post(`/coach/${id}/disable`), + + /** 获取教练教授的团课列表 */ + getCourses: (id: string) => + request.get(`/coach/${id}/courses`), +} diff --git a/gym-manage-web/src/api/groupCourse.api.ts b/gym-manage-web/src/api/groupCourse.api.ts index f1a061d..2b6db6e 100644 --- a/gym-manage-web/src/api/groupCourse.api.ts +++ b/gym-manage-web/src/api/groupCourse.api.ts @@ -5,7 +5,8 @@ import request from '@/utils/request' export interface GroupCourse { id?: number courseName: string - coachId?: number + coachId?: number | string + coachName?: string courseType?: number startTime: string endTime: string @@ -31,7 +32,7 @@ export interface GroupCoursePageRequest { keyword?: string status?: string courseType?: number - coachId?: number + coachId?: number | string startTime?: string endTime?: string sortBy?: string @@ -75,6 +76,18 @@ export const groupCourseApi = { search: (params: any) => request.post('/groupCourse/search', params), + + checkCoachConflict: (params: { + coachId: number | string + startTime: string + endTime: string + excludeCourseId?: number | string + }) => + request.post<{ + success: boolean + hasConflict: boolean + conflicts: GroupCourse[] + }>('/groupCourse/check-coach-conflict', params), } // ==================== 团课类型 ==================== diff --git a/gym-manage-web/src/router/index.ts b/gym-manage-web/src/router/index.ts index 901a1d9..5ca01bb 100644 --- a/gym-manage-web/src/router/index.ts +++ b/gym-manage-web/src/router/index.ts @@ -40,6 +40,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/system/UserManagement.vue'), meta: { title: '用户管理' } }, + { + path: 'coach', + name: 'CoachManagement', + component: () => import('@/views/system/CoachManagement.vue'), + meta: { title: '教练管理' } + }, { path: 'roles', name: 'RoleManagement', diff --git a/gym-manage-web/src/stores/permission.ts b/gym-manage-web/src/stores/permission.ts index c68b36e..dc063a2 100644 --- a/gym-manage-web/src/stores/permission.ts +++ b/gym-manage-web/src/stores/permission.ts @@ -31,6 +31,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] { 'system/user/index': '/users', 'system/role/index': '/roles', 'system/menu/index': '/menus', + 'system/coach/index': '/coach', 'system/dict/index': '/dict', 'system/config/index': '/sys/config', 'system/notice/index': '/notice', @@ -92,6 +93,7 @@ function getMenuIcon(menuName: string): string { '审计日志': 'Document', '系统监控': 'Monitor', '用户管理': 'User', + '教练管理': 'Medal', '角色管理': 'UserFilled', '菜单管理': 'Menu', '字典管理': 'Collection', diff --git a/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue b/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue index 4043007..9edc3b1 100644 --- a/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue +++ b/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue @@ -64,6 +64,11 @@ {{ getCourseTypeName(row.courseType) }} + + +