diff --git a/gym-manage-api/gym-dataCount/pom.xml b/gym-manage-api/gym-dataCount/pom.xml index 32e1c3f..9b17795 100644 --- a/gym-manage-api/gym-dataCount/pom.xml +++ b/gym-manage-api/gym-dataCount/pom.xml @@ -67,7 +67,12 @@ org.apache.poi poi-ooxml - 5.2.5 + + + + + org.apache.commons + commons-compress diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/DataStatistics.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/DataStatistics.java index 26e116d..5817fe5 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/DataStatistics.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/domain/DataStatistics.java @@ -59,6 +59,12 @@ public class DataStatistics { public static final String WEEK = "WEEK"; /** 月统计 */ 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() {} } diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java index d2185e4..074d29f 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java @@ -458,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService { String periodType = query.getPeriodType(); if (DataStatistics.PeriodType.WEEK.equals(periodType)) { - // 周统计:本周一 return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(); } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { - // 月统计:本月第一天 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 { - // 日统计:当天零点 return today.atStartOfDay(); } } @@ -478,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService { String periodType = query.getPeriodType(); if (DataStatistics.PeriodType.WEEK.equals(periodType)) { - // 周统计:本周日 23:59:59 return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59); } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { - // 月统计:本月最后一天 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 { - // 日统计:当前时间 return LocalDateTime.now(); } } @@ -503,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { startTime = date.withDayOfMonth(1).atStartOfDay(); 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 { startTime = date.atStartOfDay(); endTime = date.plusDays(1).atStartOfDay(); 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 c2473b3..911f869 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 @@ -1,5 +1,6 @@ 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.entity.GroupCourseEntity; import org.springframework.data.domain.Sort; @@ -226,4 +227,98 @@ public interface GroupCourseDao extends R2dbcRepository return spec.map((row, meta) -> row.get(0, Long.class)).one(); } + + /** + * 分页查询团课(支持 keyword 和 status 过滤) + */ + default Flux findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) { + StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL"); + List 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)); + } + + 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); + + 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) -> { + 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(); + } + + /** + * 分页查询团课总数(支持 keyword 和 status 过滤) + */ + default Mono countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) { + StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL"); + List 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(); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/CourseLabelHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/CourseLabelHandler.java index ea3a6b3..4ca9bee 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/CourseLabelHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/CourseLabelHandler.java @@ -1,5 +1,6 @@ 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.service.ICourseLabelService; import io.swagger.v3.oas.annotations.Operation; @@ -29,6 +30,13 @@ public class CourseLabelHandler { .body(courseLabelService.findAll(), CourseLabel.class); } + @Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签") + public Mono getLabelsByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> courseLabelService.findByPage(pageRequest) + .flatMap(response -> ServerResponse.ok().bodyValue(response))); + } + @Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情") public Mono getLabelById(ServerRequest request) { Long id = Long.valueOf(request.pathVariable("id")); @@ -147,17 +155,17 @@ public class CourseLabelHandler { return request.bodyToMono(Map.class) .flatMap(body -> { @SuppressWarnings("unchecked") - List labelIdsInt = (List) body.get("labelIds"); - - if (labelIdsInt == null || labelIdsInt.isEmpty()) { + List rawIds = (List) body.get("labelIds"); + + if (rawIds == null || rawIds.isEmpty()) { Map error = new HashMap<>(); error.put("success", false); error.put("message", "labelIds不能为空"); return ServerResponse.badRequest().bodyValue(error); } - - List labelIds = labelIdsInt.stream() - .map(Integer::longValue) + + List labelIds = rawIds.stream() + .map(id -> Long.valueOf(String.valueOf(id))) .collect(java.util.stream.Collectors.toList()); return courseLabelService.addLabelsToType(typeId, labelIds) diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseTypeHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseTypeHandler.java index a0b2a57..b405a11 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseTypeHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseTypeHandler.java @@ -1,5 +1,6 @@ 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.service.IGroupCourseTypeService; import io.swagger.v3.oas.annotations.Operation; @@ -134,4 +135,12 @@ public class GroupCourseTypeHandler { return ServerResponse.badRequest().bodyValue(response); }); } + + @Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型") + public Mono getGroupCourseTypesByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest) + .flatMap(response -> ServerResponse.ok().bodyValue(response)) + ); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/ICourseLabelRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/ICourseLabelRepository.java index e922354..b036fd7 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/ICourseLabelRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/ICourseLabelRepository.java @@ -1,5 +1,7 @@ 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 reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -29,4 +31,6 @@ public interface ICourseLabelRepository { Mono removeLabelFromType(Long typeId, Long labelId); Mono clearLabelsFromType(Long typeId); + + Mono> findByPage(PageRequest pageRequest); } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseTypeRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseTypeRepository.java index 3a30617..d0762f5 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseTypeRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IGroupCourseTypeRepository.java @@ -1,5 +1,7 @@ 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 reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository { Mono update(GroupCourseType groupCourseType); Mono deleteById(Long id); + + Mono> findByPage(PageRequest pageRequest); } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/CourseLabelRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/CourseLabelRepository.java index 27de680..103287f 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/CourseLabelRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/CourseLabelRepository.java @@ -1,5 +1,7 @@ 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.dao.CourseLabelDao; 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 org.slf4j.Logger; 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.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Repository @@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository { private final CourseLabelDao courseLabelDao; private final CourseTypeLabelDao courseTypeLabelDao; private final GroupCourseConverter converter; + private final R2dbcEntityTemplate r2dbcEntityTemplate; public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao, - GroupCourseConverter converter) { + GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) { this.courseLabelDao = courseLabelDao; this.courseTypeLabelDao = courseTypeLabelDao; this.converter = converter; + this.r2dbcEntityTemplate = r2dbcEntityTemplate; } @Override @@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository { } return entity; } + + @Override + public Mono> 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 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 list = tuple.getT1().stream() + .map(this::toCourseLabel) + .toList(); + return new PageResponse<>(list, totalPages, total, page, size); + }); + } } \ No newline at end of file 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 2519b5b..67729aa 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 @@ -97,37 +97,19 @@ public class GroupCourseRepository implements IGroupCourseRepository { public Mono> findByPageAndNotDeleted(PageRequest pageRequest) { int page = pageRequest.getPage(); int size = pageRequest.getSize(); - String sort = pageRequest.getSort(); - String order = pageRequest.getOrder(); - Sort sortObj = Sort.unsorted(); - if (sort != null && !sort.isEmpty()) { - sortObj = Sort.by(Sort.Direction.fromString(order), sort); - } - - 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 allEntities = tuple.getT1(); - long total = tuple.getT2(); - - int fromIndex = page * size; - int toIndex = Math.min(fromIndex + size, allEntities.size()); - - List courseList; - if (fromIndex < allEntities.size()) { - courseList = allEntities.subList(fromIndex, toIndex).stream() - .map(groupCourseConverter::toDomain) - .toList(); - } else { - courseList = List.of(); + return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest) + .flatMap(total -> { + if (total == 0) { + return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size)); } - - int totalPages = (int) Math.ceil((double) total / size); - return new PageResponse<>(courseList, totalPages, total, page, size); + return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest) + .map(groupCourseConverter::toDomain) + .collectList() + .map(courseList -> { + int totalPages = (int) Math.ceil((double) total / size); + return new PageResponse<>(courseList, totalPages, total, page, size); + }); }); } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseTypeRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseTypeRepository.java index 4d42a1d..1aa9f4e 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseTypeRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseTypeRepository.java @@ -1,5 +1,7 @@ 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.dao.GroupCourseTypeDao; 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 org.slf4j.Logger; 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.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Repository @Transactional @@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository { private final GroupCourseTypeDao groupCourseTypeDao; 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.converter = converter; + this.r2dbcEntityTemplate = r2dbcEntityTemplate; } @Override @@ -129,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository { return groupCourseTypeDao.softDelete(id, LocalDateTime.now()) .then(); } + + @Override + public Mono> 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 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 list = tuple.getT1().stream() + .map(converter::toGroupCourseType) + .toList(); + return new PageResponse<>(list, totalPages, total, page, size); + }); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/ICourseLabelService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/ICourseLabelService.java index 038dfc9..95098d3 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/ICourseLabelService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/ICourseLabelService.java @@ -1,5 +1,7 @@ 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 reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -27,4 +29,6 @@ public interface ICourseLabelService { Mono removeLabelFromType(Long typeId, Long labelId); Mono clearLabelsFromType(Long typeId); + + Mono> findByPage(PageRequest pageRequest); } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseTypeService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseTypeService.java index 3e8a921..574682e 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseTypeService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseTypeService.java @@ -1,5 +1,7 @@ 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 reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -29,4 +31,6 @@ public interface IGroupCourseTypeService { * @return 分类名称列表 */ Flux findCategories(); + + Mono> findByPage(PageRequest pageRequest); } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/CourseLabelService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/CourseLabelService.java index cb43eeb..0155470 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/CourseLabelService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/CourseLabelService.java @@ -1,5 +1,7 @@ package cn.novalon.gym.manage.groupcourse.service.impl; +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; @@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService { .doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId)) .doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage())); } + + @Override + public Mono> 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())); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java index bf1b7e0..eb14b37 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 @@ -224,8 +224,9 @@ public class GroupCourseService implements IGroupCourseService { String sort = pageRequest.getSort(); String order = pageRequest.getOrder(); 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) .flatMap(cachedJson -> { @@ -536,14 +537,15 @@ public class GroupCourseService implements IGroupCourseService { @Override public Mono delete(Long id) { - // 先查询课程状态,只有已取消的课程才能删除 + // 只有已取消或已结束的课程才能删除 return groupCourseRepository.findByIdAndDeletedAtIsNull(id) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .flatMap(course -> { - // 检查课程状态是否为已取消(状态码1) - if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { - return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " + - (course.getStatus() != null ? course.getStatus() : "未知"))); + // 检查课程状态是否为已取消(状态码1)或已结束(状态码2) + Long status = course.getStatus(); + if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) { + return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " + + (status != null ? status : "未知"))); } // 删除课程 diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseTypeService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseTypeService.java index 67ec080..a18347e 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseTypeService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseTypeService.java @@ -1,6 +1,9 @@ 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.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService; import org.slf4j.Logger; @@ -9,18 +12,18 @@ import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.util.HashSet; -import java.util.Set; - @Service public class GroupCourseTypeService implements IGroupCourseTypeService { private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class); private final IGroupCourseTypeRepository groupCourseTypeRepository; + private final IGroupCourseRepository groupCourseRepository; - public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) { + public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository, + IGroupCourseRepository groupCourseRepository) { this.groupCourseTypeRepository = groupCourseTypeRepository; + this.groupCourseRepository = groupCourseRepository; } @Override @@ -71,9 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService { @Override public Mono delete(Long id) { - return groupCourseTypeRepository.deleteById(id) - .doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) - .doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); + // 检查是否有团课依赖该类型 + return groupCourseRepository.findByCourseType(id) + .hasElements() + .flatMap(hasDependents -> { + if (hasDependents) { + return Mono.error(new RuntimeException("该类型下存在团课,无法删除")); + } + return groupCourseTypeRepository.deleteById(id) + .doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) + .doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); + }); } @Override @@ -83,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService { .filter(category -> category != null && !category.isEmpty()) .distinct(); } + + @Override + public Mono> 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())); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java index ce05953..07f2e24 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberCardHandler.java @@ -63,13 +63,24 @@ public class MemberCardHandler { @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") public Mono updateMemberCard(ServerRequest request) { Long id = Long.valueOf(request.pathVariable("id")); - return request.bodyToMono(MemberCard.class) - .flatMap(card -> { - card.setMemberCardId(id); - return memberCardService.save(card); + return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id) + .switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id))) + .zipWith(request.bodyToMono(MemberCard.class)) + .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)) - .switchIfEmpty(ServerResponse.notFound().build()); + .flatMap(updated -> ServerResponse.ok().bodyValue(updated)); } @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java index 977ca11..8e435ed 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/handler/MemberHandler.java @@ -155,8 +155,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(detail.getPhone()); detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", detail.getId(), e); - detail.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId()); + detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone())); } } return ServerResponse.ok() @@ -204,8 +204,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(member.getPhone()); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", member.getId(), e); - member.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); + member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); } } return member; @@ -222,11 +222,13 @@ public class MemberHandler { int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); 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); - // TODO: 补充签到记录 + log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}", + adminId, pageNum, pageSize, sortField, sortOrder); - return memberService.findAll(pageNum, pageSize) + return memberService.findAll(pageNum, pageSize, sortField, sortOrder) .map(member -> { // 解密手机号 if (member.getPhone() != null && !member.getPhone().isEmpty()) { @@ -234,8 +236,8 @@ public class MemberHandler { String decryptedPhone = AesUtil.decrypt(member.getPhone()); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); } catch (Exception e) { - log.error("手机号解密失败, memberId: {}", member.getId(), e); - member.setPhone(null); + log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId()); + member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone())); } } return member; diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java index ea6882c..98fdb7c 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/repository/MemberCardRepository.java @@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository "AND (:type IS NULL OR member_card_type = :type) " + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + "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 findWithConditions(Integer status, String name, String type, Double minPrice, Double maxPrice, Pageable pageable); diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java index 72e4b1f..da3127a 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/MemberService.java @@ -58,7 +58,7 @@ public interface MemberService { * @param pageSize 页大小 * @return 所有会员信息 */ - Flux findAll(Integer pageNum, Integer pageSize); + Flux findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder); /** * 前台管理端获取会员详情(含会员卡信息) diff --git a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java index ccee56c..8567be2 100644 --- a/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java +++ b/gym-manage-api/gym-member/src/main/java/cn/novalon/gym/manage/member/service/impl/MemberServiceImpl.java @@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; @@ -230,13 +231,31 @@ public class MemberServiceImpl implements MemberService { ); } + /** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */ + private static final java.util.Map ALLOWED_SORT_FIELDS = java.util.Map.of( + "createdAt", "created_at", + "memberNo", "member_no", + "gender", "gender", + "id", "id" + ); + @Override - public Flux findAll(Integer pageNum, Integer pageSize) { - log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); + public Flux findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) { + 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( pageNum - 1, - pageSize + pageSize, + Sort.by(direction, dbField) ); return memberRepository.findAllBy(pageable); @@ -249,50 +268,51 @@ public class MemberServiceImpl implements MemberService { String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; return redisUtil.get(cacheKey, MemberDetailVO.class) - .flatMap(cached -> { - if (cached != null) { - log.debug("从缓存获取会员详情, memberId: {}", memberId); - return Mono.just(cached); - } - return memberRepository.findById(memberId) - .zipWith( - memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) - .collectList(), - (baseInfo, cardList) -> { - MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); + .filter(cached -> cached != null) + .switchIfEmpty(Mono.defer(() -> + memberRepository.findById(memberId) + .switchIfEmpty(Mono.error(() -> { + log.error("会员不存在: memberId={}", memberId); + throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); + })) + .zipWith( + memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) + .collectList(), + (baseInfo, cardList) -> { + MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); - GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); - memberDetailVO.setGenderDesc(genderEnum.getDesc()); + GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); + memberDetailVO.setGenderDesc(genderEnum.getDesc()); - List enrichedCards = cardList.stream() - .peek(vo -> { - if (vo.getMemberCardType() != null) { - try { - MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); - vo.setMemberCardTypeDesc(cardType.getDesc()); - } catch (IllegalArgumentException e) { - vo.setMemberCardTypeDesc(vo.getMemberCardType()); + List enrichedCards = cardList.stream() + .peek(vo -> { + if (vo.getMemberCardType() != null) { + try { + MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); + vo.setMemberCardTypeDesc(cardType.getDesc()); + } catch (IllegalArgumentException e) { + vo.setMemberCardTypeDesc(vo.getMemberCardType()); + } } - } - if (vo.getMemberCardStatus() != null) { - vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); - } - }) - .collect(Collectors.toList()); - memberDetailVO.setMemberCards(enrichedCards); + if (vo.getMemberCardStatus() != null) { + vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); + } + }) + .collect(Collectors.toList()); + memberDetailVO.setMemberCards(enrichedCards); - long activeCount = enrichedCards.stream() - .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) - .count(); - memberDetailVO.setActiveCardCount((int) activeCount); - memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); + long activeCount = enrichedCards.stream() + .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) + .count(); + memberDetailVO.setActiveCardCount((int) activeCount); + memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); - return memberDetailVO; - } - ) - .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) - .then(Mono.just(vo))); - }); + return memberDetailVO; + } + ) + .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) + .then(Mono.just(vo))) + )); } 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 dfaef6c..ef16328 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 @@ -257,8 +257,11 @@ public class SystemRouter { // ===== 会员卡类型管理 ===== .GET("/api/member-cards/active", memberCardHandler::getActiveCards) + .GET("/api/member-cards", memberCardHandler::listMemberCards) .GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById) .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) @@ -305,6 +308,7 @@ public class SystemRouter { .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory) .GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById) .POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType) + .POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage) .PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType) .DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType) @@ -313,6 +317,7 @@ public class SystemRouter { .GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels) .GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById) .GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId) + .POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage) .POST("/api/groupCourse/labels", courseLabelHandler::createLabel) .PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel) .DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel) diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java index 6b9f23c..483e1a4 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/config/RedisConfig.java @@ -1,5 +1,9 @@ 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.Configuration; 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.StringRedisSerializer; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + /** * Redis 配置类(响应式版本) * @@ -17,20 +24,41 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration 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 */ @Bean public ReactiveRedisTemplate reactiveRedisTemplate( ReactiveRedisConnectionFactory connectionFactory) { - + + GenericJackson2JsonRedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(createRedisObjectMapper()); + // 配置序列化上下文 RedisSerializationContext serializationContext = RedisSerializationContext.newSerializationContext() .key(StringRedisSerializer.UTF_8) - .value(new GenericJackson2JsonRedisSerializer()) + .value(jsonSerializer) .hashKey(StringRedisSerializer.UTF_8) - .hashValue(new GenericJackson2JsonRedisSerializer()) + .hashValue(jsonSerializer) .build(); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/dto/PageRequest.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/dto/PageRequest.java index 9054f19..6215032 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/dto/PageRequest.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/dto/PageRequest.java @@ -12,6 +12,8 @@ public class PageRequest { private String sort = "id"; private String order = "asc"; private String keyword; + private String status; + private String category; public int getPage() { return page; @@ -52,4 +54,20 @@ public class PageRequest { public void setKeyword(String 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; + } } diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java index e880dee..32245a5 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/RedisUtil.java @@ -1,5 +1,6 @@ package cn.novalon.gym.manage.common.util; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.stereotype.Component; @@ -13,6 +14,7 @@ import java.time.Duration; * @author liwentao * @date 2026/5/15 */ +@Slf4j @Component public class RedisUtil { @@ -34,12 +36,19 @@ public class RedisUtil { } /** - * 获取值 + * 获取值(带类型转换) + * filter+cast 替代 map 避免 NPE,onErrorResume 兼容旧缓存格式反序列化失败 */ @SuppressWarnings("unchecked") public Mono get(String key, Class clazz) { return reactiveRedisTemplate.opsForValue().get(key) - .map(obj -> clazz.isInstance(obj) ? (T) obj : null); + .filter(clazz::isInstance) + .cast(clazz) + .onErrorResume(e -> { + // 旧缓存数据格式不兼容时静默跳过,后续逻辑会 fallback 到 DB 查询 + log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage()); + return Mono.empty(); + }); } /** diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_Business_Menus.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_Business_Menus.sql new file mode 100644 index 0000000..25f722c --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_Business_Menus.sql @@ -0,0 +1,47 @@ +-- ============================================ +-- V28: 新增业务模块菜单 +-- 描述: 为团课管理、团课类型、团课标签、团课推荐创建菜单项 +-- ============================================ + +-- 团课管理一级菜单(目录) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(400, '团课管理', 0, 4, 'M', NULL, NULL, 1, NOW(), NOW()); + +-- 团课管理子菜单(页面) +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()); + +-- 团课管理按钮权限 +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()); + +-- 团课类型按钮权限 +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(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()); + +-- 团课标签按钮权限 +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(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()); + +-- 团课推荐按钮权限 +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(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()); + +-- 重置菜单序列 +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/V29__Add_Member_Management_Menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Add_Member_Management_Menu.sql new file mode 100644 index 0000000..0e2c961 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Add_Member_Management_Menu.sql @@ -0,0 +1,23 @@ +-- ============================================ +-- V29: 新增会员管理菜单 +-- 描述: 为会员管理功能创建菜单项 +-- ============================================ + +-- 会员管理一级菜单(目录) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(500, '会员管理', 0, 5, 'M', NULL, NULL, 1, NOW(), NOW()); + +-- 会员管理子菜单(页面) +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()); + +-- 会员管理按钮权限 +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()); + +-- 重置菜单序列 +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/V30__Add_Member_Card_Management_Menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V30__Add_Member_Card_Management_Menu.sql new file mode 100644 index 0000000..420f7bf --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V30__Add_Member_Card_Management_Menu.sql @@ -0,0 +1,22 @@ +-- ============================================ +-- V30: 新增会员卡管理菜单 +-- 描述: 为会员卡管理功能创建菜单项 +-- ============================================ + +-- 会员卡管理一级菜单(目录) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(502, '会员卡管理', 0, 6, 'M', NULL, NULL, 1, NOW(), NOW()); + +-- 会员卡管理子菜单(页面) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(503, '会员卡管理', 502, 1, 'C', 'business:memberCard:list', 'member/card/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 +(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()); + +-- 重置菜单序列 +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/V31__Add_Statistics_Menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V31__Add_Statistics_Menu.sql new file mode 100644 index 0000000..60a94e1 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V31__Add_Statistics_Menu.sql @@ -0,0 +1,19 @@ +-- ============================================ +-- V31: 新增数据统计菜单 +-- 描述: 为数据统计看板功能创建菜单项 +-- ============================================ + +-- 数据统计一级菜单(目录) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(504, '数据统计', 0, 7, 'M', NULL, NULL, 1, NOW(), NOW()); + +-- 数据统计子菜单(页面) +INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES +(505, '数据统计看板', 504, 1, 'C', 'business:statistics:view', 'statistics/dashboard/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 +(5051, '数据统计导出', 505, 1, 'F', 'business:statistics:export', NULL, 1, NOW(), NOW()); + +-- 重置菜单序列 +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/V32__Add_Statistics_Test_Data_Today.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V32__Add_Statistics_Test_Data_Today.sql new file mode 100644 index 0000000..c1a3b8f --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V32__Add_Statistics_Test_Data_Today.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- V32: 新增测试数据(用于数据统计看板展示) +-- 描述: 基于当天日期,创建跨多个时间区间的会员、签到、预约数据 +-- 覆盖时间区间: 今日、本周、本月、近30天、近90天、今年 +-- ============================================================ + +-- ============================================================ +-- 1. 新增会员(100人,ID 2001-2100) +-- 按时间分布:今日10人 / 本周20人 / 本月30人 / 近30天60人 / 近90天85人 / 今年100人 +-- 创建日期从 180 天前均匀分布到今天 +-- ============================================================ +INSERT INTO member_user (id, member_no, nickname, phone, gender, subscribed, is_deleted, created_at, updated_at) +SELECT + 2000 + g, + 'M' || TO_CHAR(CURRENT_DATE - (g % 180), 'YYYYMMDD') || LPAD(g::text, 3, '0'), + '测试会员' || g, + '138' || LPAD(g::text, 8, '0'), + (g % 3)::int, + false, + false, + (CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp, + (CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp +FROM generate_series(1, 100) AS g +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. 签到记录(80条,使用已有会员 ID 1001-1010) +-- 按时间分布:最近90天,每天约1条 +-- 签到类型:QR_CODE/MANUAL/FACE 均匀分布 +-- 签到状态:SUCCESS(90%) / FAILED(10%) +-- ============================================================ +INSERT INTO sign_in_record (member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete, created_at, updated_at) +SELECT + 1001 + (g % 10), + (CURRENT_DATE - (g % 90) + TIME '08:00:00' + (INTERVAL '1 hour' * (g % 12)))::timestamp, + CASE (g % 3) WHEN 0 THEN 'QR_CODE' WHEN 1 THEN 'MANUAL' ELSE 'FACE' END, + CASE WHEN (g % 10) = 0 THEN 'FAILED' ELSE 'SUCCESS' END, + CASE (g % 2) WHEN 0 THEN 'MINI_PROGRAM' ELSE 'PC_BACKEND' END, + false, + (CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp, + (CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp +FROM generate_series(1, 80) AS g; + +-- ============================================================ +-- 3. 团课预约(40条,使用已有会员 ID 1001-1010) +-- 按时间分布:最近90天 +-- 状态分布:出勤(2) 60% / 取消(1) 20% / 缺席(3) 20% +-- ============================================================ +INSERT INTO group_course_booking (id, course_id, member_id, member_card_id, booking_time, status, created_at, updated_at) +SELECT + 5100 + g, + CASE (g % 3) WHEN 0 THEN 3001 WHEN 1 THEN 3002 ELSE 3003 END, + 1001 + (g % 10), + (SELECT id FROM member_card_record ORDER BY id LIMIT 1), + (CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp, + CASE WHEN (g % 5) = 0 THEN '1' WHEN (g % 5) = 1 THEN '3' ELSE '2' END, + (CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp, + (CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp +FROM generate_series(1, 40) AS g; + +-- ============================================================ +-- 4. 重置序列(防止后续自动插入 ID 冲突) +-- ============================================================ +SELECT setval('member_user_id_seq', (SELECT COALESCE(MAX(id), 1) FROM member_user)); +SELECT setval('group_course_booking_id_seq', (SELECT COALESCE(MAX(id), 1) FROM group_course_booking)); +SELECT setval('sign_in_record_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sign_in_record)); diff --git a/gym-manage-api/manage-sys/pom.xml b/gym-manage-api/manage-sys/pom.xml index 72f044d..7f48bb7 100644 --- a/gym-manage-api/manage-sys/pom.xml +++ b/gym-manage-api/manage-sys/pom.xml @@ -106,6 +106,12 @@ org.apache.poi poi-ooxml + + + + org.apache.commons + commons-compress + diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index 796ce9b..385258e 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -213,6 +213,11 @@ poi-ooxml ${poi.version} + + org.apache.commons + commons-compress + 1.21 + diff --git a/gym-manage-web/src/api/groupCourse.api.ts b/gym-manage-web/src/api/groupCourse.api.ts new file mode 100644 index 0000000..f1a061d --- /dev/null +++ b/gym-manage-web/src/api/groupCourse.api.ts @@ -0,0 +1,268 @@ +import request from '@/utils/request' + +// ==================== 团课课程 ==================== + +export interface GroupCourse { + id?: number + courseName: string + coachId?: number + courseType?: number + startTime: string + endTime: string + maxMembers?: number + currentMembers?: number + status?: string + actualStartTime?: string + actualEndTime?: string + location?: string + coverImage?: string + description?: string + storedValueAmount?: number + qrCodePath?: string + createBy?: string + updateBy?: string + createdAt?: string + updatedAt?: string +} + +export interface GroupCoursePageRequest { + page: number + size: number + keyword?: string + status?: string + courseType?: number + coachId?: number + startTime?: string + endTime?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' +} + +export interface PageResponse { + content: T[] + totalElements: number + totalPages: number + size: number + number: number + first: boolean + last: boolean +} + +export const groupCourseApi = { + getAll: () => + request.get('/groupCourse/list'), + + getPage: (params: GroupCoursePageRequest) => + request.post>('/groupCourse/page', params), + + getById: (id: number) => + request.get(`/groupCourse/${id}`), + + getDetailById: (id: number) => + request.get(`/groupCourse/${id}/detail`), + + create: (data: GroupCourse) => + request.post('/groupCourse', data), + + update: (id: number, data: GroupCourse) => + request.put(`/groupCourse/${id}`, data), + + delete: (id: number) => + request.delete(`/groupCourse/${id}`), + + cancel: (id: number) => + request.post(`/groupCourse/${id}/cancel`), + + search: (params: any) => + request.post('/groupCourse/search', params), +} + +// ==================== 团课类型 ==================== + +export interface GroupCourseType { + id?: number + typeName: string + baseDifficulty?: number + description?: string + category?: string + createBy?: string + updateBy?: string + createdAt?: string + updatedAt?: string +} + +export interface GroupCourseTypePageRequest { + page: number + size: number + keyword?: string + category?: string + sort?: string + order?: 'asc' | 'desc' +} + +export interface CourseLabelPageRequest { + page: number + size: number + keyword?: string + sort?: string + order?: 'asc' | 'desc' +} + +export const groupCourseTypeApi = { + getAll: () => + request.get('/groupCourse/types'), + + search: (params: any) => + request.get('/groupCourse/types/search', { params }), + + getCategories: () => + request.get('/groupCourse/types/categories'), + + getByCategory: (category: string) => + request.get(`/groupCourse/types/category/${encodeURIComponent(category)}`), + + getById: (id: number) => + request.get(`/groupCourse/types/${id}`), + + getPage: (params: GroupCourseTypePageRequest) => + request.post>('/groupCourse/types/page', params), + + create: (data: GroupCourseType) => + request.post('/groupCourse/types', data), + + update: (id: number, data: GroupCourseType) => + request.put(`/groupCourse/types/${id}`, data), + + delete: (id: number) => + request.delete(`/groupCourse/types/${id}`), +} + +// ==================== 团课标签 ==================== + +export interface CourseLabel { + id?: number + labelName: string + color?: string + description?: string + createBy?: string + updateBy?: string + createdAt?: string + updatedAt?: string +} + +export const courseLabelApi = { + getAll: () => + request.get('/groupCourse/labels'), + + search: (params: any) => + request.get('/groupCourse/labels/search', { params }), + + getById: (id: number) => + request.get(`/groupCourse/labels/${id}`), + + getByTypeId: (typeId: number) => + request.get(`/groupCourse/types/${typeId}/labels`), + + getPage: (params: CourseLabelPageRequest) => + request.post>('/groupCourse/labels/page', params), + + create: (data: CourseLabel) => + request.post('/groupCourse/labels', data), + + update: (id: number, data: CourseLabel) => + request.put(`/groupCourse/labels/${id}`, data), + + delete: (id: number) => + request.delete(`/groupCourse/labels/${id}`), + + addLabelsToType: (typeId: number, labelIds: number[]) => + request.post(`/groupCourse/types/${typeId}/labels`, { labelIds }), + + removeLabelFromType: (typeId: number, labelId: number) => + request.delete(`/groupCourse/types/${typeId}/labels/${labelId}`), + + clearLabelsFromType: (typeId: number) => + request.delete(`/groupCourse/types/${typeId}/labels`), +} + +// ==================== 团课推荐 ==================== + +export interface GroupCourseRecommend { + id?: number + courseId: number + recommendTitle?: string + recommendContent?: string + recommendReason?: string + priority?: number + isActive?: boolean + createBy?: string + updateBy?: string + createdAt?: string + updatedAt?: string +} + +export const groupCourseRecommendApi = { + getAll: () => + request.get('/groupCourse/recommend/list'), + + getActive: () => + request.get('/groupCourse/recommend/active'), + + getById: (id: number) => + request.get(`/groupCourse/recommend/${id}`), + + getByCourseId: (courseId: number) => + request.get(`/groupCourse/recommend/course/${courseId}`), + + create: (data: GroupCourseRecommend) => + request.post('/groupCourse/recommend', data), + + update: (id: number, data: GroupCourseRecommend) => + request.put(`/groupCourse/recommend/${id}`, data), + + delete: (id: number) => + request.delete(`/groupCourse/recommend/${id}`), + + enable: (id: number) => + request.post(`/groupCourse/recommend/${id}/enable`), + + disable: (id: number) => + request.post(`/groupCourse/recommend/${id}/disable`), +} + +// ==================== 团课预约 ==================== + +export interface GroupCourseBooking { + id?: number + courseId: number + memberId: number + memberCardId: number + bookingTime?: string + status?: string + cancelTime?: string + courseName?: string + courseStartTime?: string + courseEndTime?: string + location?: string + createBy?: string + updateBy?: string + createdAt?: string + updatedAt?: string +} + +export const groupCourseBookingApi = { + book: (data: GroupCourseBooking) => + request.post('/groupCourse/book', data), + + cancel: (bookingId: number) => + request.post(`/groupCourse/booking/${bookingId}/cancel`), + + getByMemberId: (memberId: number) => + request.get(`/groupCourse/bookings/member/${memberId}`), + + getByCourseId: (courseId: number) => + request.get(`/groupCourse/bookings/course/${courseId}`), + + getById: (bookingId: number) => + request.get(`/groupCourse/bookings/${bookingId}`), +} diff --git a/gym-manage-web/src/api/member.api.ts b/gym-manage-web/src/api/member.api.ts new file mode 100644 index 0000000..136900d --- /dev/null +++ b/gym-manage-web/src/api/member.api.ts @@ -0,0 +1,107 @@ +import request from '@/utils/request' + +// ==================== 会员基础信息 ==================== + +export interface Member { + id?: number | string + memberNo?: string + nickname?: string + phone?: string + gender?: number + genderDesc?: string + avatar?: string + createdAt?: string + updatedAt?: string +} + +export interface MemberDetail { + id?: number + memberNo?: string + nickname?: string + phone?: string + gender?: number + genderDesc?: string + birthday?: string + address?: string + avatar?: string + subscribed?: boolean + lastLoginAt?: string + createdAt?: string + memberCards?: MemberCardInfo[] + activeCardCount?: number + inactiveCardCount?: number +} + +export interface MemberCardInfo { + id?: number + memberCardId?: number + memberCardName?: string + memberCardType?: string + memberCardTypeDesc?: string + memberCardPrice?: number + memberCardValidityDays?: number + memberCardTotalTimes?: number + memberCardAmount?: number + memberCardStatus?: string + memberCardStatusDesc?: string + extraConfig?: string + createdAt?: string + updatedAt?: string +} + +export interface MemberCardRecord { + id?: number + memberCardRecordId?: number + memberId?: number + memberCardId?: number + memberCardName?: string + memberCardType?: string + memberCardPrice?: number + memberCardValidityDays?: number + memberCardTotalTimes?: number + status?: string + remainingTimes?: number + remainingAmount?: number + expireTime?: string + purchaseTime?: string +} + +export interface UpdateMemberDto { + nickname?: string + gender?: string + birthday?: string + avatar?: string + address?: string +} + +export interface UpdatePhoneDto { + phone: string +} + +export const memberApi = { + /** 搜索会员 */ + search: (params: { searchValue?: string; pageNum?: number; pageSize?: number }) => + request.get('/admin/members', { params }), + + /** 获取所有会员 */ + getAll: (params: { pageNum?: number; pageSize?: number; sortField?: string; sortOrder?: string }) => + request.get('/admin/members/all', { params }), + + /** 获取会员详情 */ + getDetail: (id: number) => + request.get(`/admin/member/${id}`), + + /** 更新会员信息 */ + update: (id: number, data: UpdateMemberDto) => + request.put(`/admin/member/${id}`, data), + + /** 管理员更新会员手机号 */ + updatePhone: (id: number, data: UpdatePhoneDto) => + request.post(`/admin/member/${id}/phone`, data), + + /** 获取会员绑定的会员卡记录 */ + getMemberCardRecords: (memberId: number) => + request.get(`/member-card-records/my-cards-with-status/${memberId}`, { + params: { status: 'all' }, + }), +} diff --git a/gym-manage-web/src/api/memberCard.api.ts b/gym-manage-web/src/api/memberCard.api.ts new file mode 100644 index 0000000..75bc3a0 --- /dev/null +++ b/gym-manage-web/src/api/memberCard.api.ts @@ -0,0 +1,54 @@ +import request from '@/utils/request' + +// ==================== 会员卡类型 ==================== + +export interface MemberCardType { + id?: number + memberCardId?: number + memberCardName?: string + memberCardType?: string + memberCardTypeDesc?: string + memberCardPrice?: number + memberCardValidityDays?: number + memberCardTotalTimes?: number + memberCardAmount?: number + memberCardStatus?: number + memberCardStatusDesc?: string + extraConfig?: string + createdAt?: string + updatedAt?: string +} + +export interface MemberCardPageParams { + status?: number + name?: string + type?: string + page?: number + size?: number +} + +export const memberCardApi = { + /** 查询会员卡类型列表(分页+条件) */ + list: (params: MemberCardPageParams) => + request.get('/member-cards', { params }), + + /** 查询上架中的会员卡类型 */ + getActiveCards: (status?: number) => + request.get('/member-cards/active', { params: { status } }), + + /** 根据ID查询会员卡类型 */ + getById: (memberCardId: number) => + request.get(`/member-cards/${memberCardId}`), + + /** 创建会员卡类型 */ + create: (data: MemberCardType) => + request.post('/member-cards', data), + + /** 更新会员卡类型 */ + update: (id: number, data: MemberCardType) => + request.put(`/member-cards/${id}`, data), + + /** 删除会员卡类型 */ + delete: (id: number) => + request.delete(`/member-cards/${id}`), +} diff --git a/gym-manage-web/src/api/statistics.api.ts b/gym-manage-web/src/api/statistics.api.ts new file mode 100644 index 0000000..53f36e4 --- /dev/null +++ b/gym-manage-web/src/api/statistics.api.ts @@ -0,0 +1,100 @@ +import request from '@/utils/request' + +// ========== 接口类型定义 ========== + +export interface MemberStatistics { + statDate: string + newMembers: number + activeMembers: number + totalMembers: number + signInMembers: number + bookingMembers: number + cancelBookingMembers: number +} + +export interface BookingStatistics { + statDate: string + newBookings: number + cancelBookings: number + attendBookings: number + absentBookings: number + attendanceRate: number + cancelRate: number + bookingMembers: number + cancelMembers: number +} + +export interface SignInStatistics { + statDate: string + totalSignIns: number + successSignIns: number + failedSignIns: number + successRate: number + signInMembers: number + qrCodeSignIns: number + manualSignIns: number + faceSignIns: number +} + +export interface StatisticsSummary { + statDate: string + memberStatistics: MemberStatistics + bookingStatistics: BookingStatistics + signInStatistics: SignInStatistics + generatedAt: string +} + +export interface HistoryRecord { + statType: string + periodType: string + statDate: string + count: number + relatedId: number + extraData: string +} + +export interface StatisticsQuery { + statType?: string + periodType?: string + startTime?: string + endTime?: string + page?: number + size?: number +} + +// ========== API ========== + +export const statisticsApi = { + /** 获取综合统计摘要 */ + getSummary(params?: StatisticsQuery) { + return request.get('/datacount/summary', { params }) + }, + + /** 获取会员统计 */ + getMember(params?: StatisticsQuery) { + return request.get('/datacount/member', { params }) + }, + + /** 获取预约统计 */ + getBooking(params?: StatisticsQuery) { + return request.get('/datacount/booking', { params }) + }, + + /** 获取签到统计 */ + getSignIn(params?: StatisticsQuery) { + return request.get('/datacount/signin', { params }) + }, + + /** 查询历史统计数据 */ + getHistory(params?: StatisticsQuery) { + return request.get('/datacount/history', { params }) + }, + + /** 导出统计报表 (Excel) */ + exportStatistics(params?: StatisticsQuery) { + return request.get('/datacount/export', { + params, + responseType: 'blob', + }) + }, +} diff --git a/gym-manage-web/src/layouts/DefaultLayout.vue b/gym-manage-web/src/layouts/DefaultLayout.vue index d346ccb..69468ec 100644 --- a/gym-manage-web/src/layouts/DefaultLayout.vue +++ b/gym-manage-web/src/layouts/DefaultLayout.vue @@ -97,15 +97,15 @@ onMounted(async () => { if (!token) { router.push('/login') - } else if (!permissionStore.loaded) { - permissionStore.initFromStorage() - - if (!permissionStore.loaded || permissionStore.menus.length === 0) { - try { - await permissionStore.fetchUserMenus() - } catch (error) { - console.error('获取用户菜单失败:', error) - } + } else { + // 先尝试从 localStorage 快速恢复,再异步获取最新菜单 + if (!permissionStore.loaded) { + permissionStore.initFromStorage() + } + try { + await permissionStore.fetchUserMenus() + } catch (error) { + console.error('获取用户菜单失败:', error) } } }) diff --git a/gym-manage-web/src/router/index.ts b/gym-manage-web/src/router/index.ts index 13b66b8..303e2ea 100644 --- a/gym-manage-web/src/router/index.ts +++ b/gym-manage-web/src/router/index.ts @@ -93,6 +93,48 @@ const routes: RouteRecordRaw[] = [ name: 'ExceptionLog', component: () => import('@/views/audit/ExceptionLog.vue'), meta: { title: '异常日志' } + }, + { + path: 'courses', + name: 'GroupCourseManagement', + component: () => import('@/views/groupcourse/GroupCourseManagement.vue'), + meta: { title: '团课管理' } + }, + { + path: 'courses/types', + name: 'CourseTypeManagement', + component: () => import('@/views/groupcourse/CourseTypeManagement.vue'), + meta: { title: '团课类型' } + }, + { + path: 'courses/labels', + name: 'CourseLabelManagement', + component: () => import('@/views/groupcourse/CourseLabelManagement.vue'), + meta: { title: '团课标签' } + }, + { + path: 'courses/recommend', + name: 'CourseRecommendManagement', + component: () => import('@/views/groupcourse/CourseRecommendManagement.vue'), + meta: { title: '团课推荐' } + }, + { + path: 'members', + name: 'MemberManagement', + component: () => import('@/views/member/MemberManagement.vue'), + meta: { title: '会员管理' } + }, + { + path: 'member-cards', + name: 'MemberCardManagement', + component: () => import('@/views/member/MemberCardManagement.vue'), + meta: { title: '会员卡管理' } + }, + { + path: 'statistics', + name: 'StatisticsDashboard', + component: () => import('@/views/statistics/StatisticsDashboard.vue'), + meta: { title: '数据统计看板' } } ] } diff --git a/gym-manage-web/src/stores/permission.ts b/gym-manage-web/src/stores/permission.ts index 119fff9..513427e 100644 --- a/gym-manage-web/src/stores/permission.ts +++ b/gym-manage-web/src/stores/permission.ts @@ -38,6 +38,13 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] { 'audit/operation/index': '/oplog', 'audit/login/index': '/loginlog', 'audit/exception/index': '/exceptionlog', + 'gymCourse/course/index': '/courses', + 'gymCourse/type/index': '/courses/types', + 'gymCourse/label/index': '/courses/labels', + 'gymCourse/recommend/index': '/courses/recommend', + 'member/member/index': '/members', + 'member/card/index': '/member-cards', + 'statistics/dashboard/index': '/statistics', } const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F') @@ -92,7 +99,14 @@ function getMenuIcon(menuName: string): string { '文件管理': 'Folder', '操作日志': 'Document', '登录日志': 'Document', - '异常日志': 'Warning' + '异常日志': 'Warning', + '团课管理': 'TrophyBase', + '团课类型': 'Grid', + '团课标签': 'Discount', + '团课推荐': 'Star', + '会员管理': 'Avatar', + '会员卡管理': 'CreditCard', + '数据统计': 'DataAnalysis', } return iconMap[menuName] || 'Document' } diff --git a/gym-manage-web/src/views/groupcourse/CourseLabelManagement.vue b/gym-manage-web/src/views/groupcourse/CourseLabelManagement.vue new file mode 100644 index 0000000..36e57b3 --- /dev/null +++ b/gym-manage-web/src/views/groupcourse/CourseLabelManagement.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/gym-manage-web/src/views/groupcourse/CourseRecommendManagement.vue b/gym-manage-web/src/views/groupcourse/CourseRecommendManagement.vue new file mode 100644 index 0000000..b5e0933 --- /dev/null +++ b/gym-manage-web/src/views/groupcourse/CourseRecommendManagement.vue @@ -0,0 +1,331 @@ + + + + + diff --git a/gym-manage-web/src/views/groupcourse/CourseTypeManagement.vue b/gym-manage-web/src/views/groupcourse/CourseTypeManagement.vue new file mode 100644 index 0000000..b1b054b --- /dev/null +++ b/gym-manage-web/src/views/groupcourse/CourseTypeManagement.vue @@ -0,0 +1,455 @@ + + + + + diff --git a/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue b/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue new file mode 100644 index 0000000..ba45409 --- /dev/null +++ b/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/gym-manage-web/src/views/member/MemberCardManagement.vue b/gym-manage-web/src/views/member/MemberCardManagement.vue new file mode 100644 index 0000000..933fa16 --- /dev/null +++ b/gym-manage-web/src/views/member/MemberCardManagement.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/gym-manage-web/src/views/member/MemberManagement.vue b/gym-manage-web/src/views/member/MemberManagement.vue new file mode 100644 index 0000000..1e68cca --- /dev/null +++ b/gym-manage-web/src/views/member/MemberManagement.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/gym-manage-web/src/views/statistics/StatisticsDashboard.vue b/gym-manage-web/src/views/statistics/StatisticsDashboard.vue new file mode 100644 index 0000000..42efa28 --- /dev/null +++ b/gym-manage-web/src/views/statistics/StatisticsDashboard.vue @@ -0,0 +1,402 @@ + + + + +