Author SHA1 Message Date
liwentao 593f6f13e7 新增数据统计页面 2026-07-10 13:56:16 +08:00
liwentao 8ee326b43a 修补缺失文件 2026-07-09 15:34:50 +08:00
liwentao 8fb4e714d8 新增会员卡管理 2026-07-09 15:33:05 +08:00
liwentao a8e69e0850 新增会员管理 2026-07-09 14:45:45 +08:00
liwentao f5b5724e92 完善团课相关管理 2026-07-08 17:02:04 +08:00
liwentao 1e41f31271 更新后台管理系统,实现团课管理基础显示 2026-07-06 16:44:27 +08:00
168 changed files with 7665 additions and 20565 deletions
@@ -194,47 +194,4 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats))); .bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
} }
/**
* 管理员查询所有签到记录(支持排序)
*
* GET /api/checkIn/admin/records
*/
public Mono<ServerResponse> getAllSignInRecords(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
String sortBy = request.queryParam("sortBy").orElse("signInTime");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询所有签到记录, startDate: {}, endDate: {}, sortBy: {}, sortOrder: {}", startDate, endDate, sortBy, sortOrder);
return checkService.getAllSignInRecords(startDate, endDate, sortBy, sortOrder)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 管理员查询签到统计(不限会员)
*
* GET /api/checkIn/admin/statistics
*/
public Mono<ServerResponse> getAllSignInStatistics(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询签到统计, startDate: {}, endDate: {}", startDate, endDate);
return checkService.getAllSignInStats(startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
} }
@@ -57,12 +57,6 @@ public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Lo
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC") @Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime); Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录(支持动态排序)
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRangeSorted(LocalDateTime startTime, LocalDateTime endTime);
/** /**
* 统计会员在时间范围内的签到次数 * 统计会员在时间范围内的签到次数
*/ */
@@ -78,20 +78,4 @@ public interface ICheckInService {
* @return 签到统计VO * @return 签到统计VO
*/ */
Mono<SignInStatsVO> getDailySignInStats(LocalDate date); Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
/**
* 管理员查询所有签到记录(支持排序)
*
* @param startTime 开始时间
* @param endTime 结束时间
* @param sortBy 排序字段
* @param sortOrder 排序方向
* @return 签到记录列表(含会员姓名、卡类型)
*/
Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder);
/**
* 管理员查询签到统计(不限会员)
*/
Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime);
} }
@@ -16,13 +16,11 @@ import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants; import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -49,7 +47,6 @@ public class CheckServiceImpl implements ICheckInService {
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository; private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService; private final IGroupCourseBookingService groupCourseBookingService;
private final IMemberRepository memberRepository;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -433,83 +430,6 @@ public class CheckServiceImpl implements ICheckInService {
return vo; return vo;
} }
@Override
public Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
Flux<SignInRecord> recordFlux = signInRecordRepository.findByTimeRangeSorted(start, end);
return recordFlux
.flatMap(record -> {
// fetch member name
Mono<String> memberNameMono = memberRepository.findById(record.getMemberId())
.map(Member::getNickname)
.defaultIfEmpty("未知");
// fetch card type name
Mono<String> cardTypeMono = record.getMemberCardId() != null
? memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
.map(MemberCard::getMemberCardName)
.defaultIfEmpty("未知")
: Mono.just("-");
return Mono.zip(memberNameMono, cardTypeMono)
.map(tuple -> {
SignInRecordVO vo = convertToVO(record);
vo.setMemberName(tuple.getT1());
vo.setMemberCardType(tuple.getT2());
return vo;
});
})
.collectList()
.flatMapMany(list -> {
// in-memory sort
boolean asc = "asc".equalsIgnoreCase(sortOrder);
java.util.Comparator<SignInRecordVO> comparator;
switch (sortBy != null ? sortBy : "signInTime") {
case "id":
comparator = java.util.Comparator.comparing(SignInRecordVO::getId, java.util.Comparator.nullsLast(Long::compareTo));
break;
case "memberName":
comparator = java.util.Comparator.comparing(SignInRecordVO::getMemberName, java.util.Comparator.nullsLast(String::compareTo));
break;
case "signInTime":
default:
comparator = java.util.Comparator.comparing(SignInRecordVO::getSignInTime, java.util.Comparator.nullsLast(java.time.LocalDateTime::compareTo));
break;
}
if (!asc) {
comparator = comparator.reversed();
}
list.sort(comparator);
return Flux.fromIterable(list);
});
}
@Override
public Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
Long members = (Long) results[2];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(startTime);
stats.setEndDate(endTime);
stats.setUniqueMemberCount(members);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByTimeRange(start, end),
signInRecordRepository.countSuccessByTimeRange(start, end),
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
);
}
private long getSecondsUntilEndOfDay() { private long getSecondsUntilEndOfDay() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
@@ -59,16 +59,6 @@ public class SignInRecordVO {
*/ */
private String source; private String source;
/**
* 会员姓名(关联查询)
*/
private String memberName;
/**
* 会员卡类型名称(关联查询)
*/
private String memberCardType;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -14,7 +14,6 @@ import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -58,9 +57,6 @@ class CheckInModuleTest {
@Mock @Mock
private IGroupCourseBookingService groupCourseBookingService; private IGroupCourseBookingService groupCourseBookingService;
@Mock
private IMemberRepository memberRepository;
@Mock @Mock
private MemberCard mockMemberCard; private MemberCard mockMemberCard;
@@ -76,7 +72,7 @@ class CheckInModuleTest {
void setUp() { void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository, checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
memberCardRepository, signInRecordRepository, groupCourseBookingService, memberRepository); memberCardRepository, signInRecordRepository, groupCourseBookingService);
when(mockMemberCard.getId()).thenReturn(1L); when(mockMemberCard.getId()).thenReturn(1L);
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD"); when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
+6 -1
View File
@@ -67,7 +67,12 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.2.5</version> </dependency>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency> </dependency>
<!-- Gym Modules --> <!-- Gym Modules -->
@@ -26,7 +26,7 @@ public class DataStatisticsDao {
* 统计指定时间范围内新增会员数 * 统计指定时间范围内新增会员数
*/ */
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) { public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND deleted_at IS NULL") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false")
.bind("startTime", startTime) .bind("startTime", startTime)
.bind("endTime", endTime) .bind("endTime", endTime)
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
@@ -37,7 +37,7 @@ public class DataStatisticsDao {
* 统计总会员数 * 统计总会员数
*/ */
public Mono<Long> countTotalMembers() { public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE deleted_at IS NULL") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false")
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
.one(); .one();
} }
@@ -59,6 +59,12 @@ public class DataStatistics {
public static final String WEEK = "WEEK"; public static final String WEEK = "WEEK";
/** 月统计 */ /** 月统计 */
public static final String MONTH = "MONTH"; public static final String MONTH = "MONTH";
/** 近30天 */
public static final String LAST_30_DAYS = "LAST_30_DAYS";
/** 近90天 */
public static final String LAST_90_DAYS = "LAST_90_DAYS";
/** 今年 */
public static final String YEAR = "YEAR";
private PeriodType() {} private PeriodType() {}
} }
@@ -4,8 +4,6 @@ import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService; import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -27,8 +25,6 @@ import java.time.format.DateTimeFormatter;
@Tag(name = "数据统计", description = "数据统计相关操作") @Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler { public class DataStatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsHandler.class);
@Autowired @Autowired
private IDataStatisticsService dataStatisticsService; private IDataStatisticsService dataStatisticsService;
@@ -39,7 +35,6 @@ public class DataStatisticsHandler {
return dataStatisticsService.getStatisticsSummaryWithCache(query) return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary)) .flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> { .onErrorResume(e -> {
log.error("获取综合统计数据失败", e);
StatisticsSummary errorSummary = StatisticsSummary.builder() StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
@@ -13,7 +13,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -23,9 +22,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -176,25 +173,9 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
@Override @Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) { public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
String statDate = query.getStartTime() != null Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
? query.getStartTime().toLocalDate().toString() Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
: LocalDateTime.now().toLocalDate().toString(); Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query)
.onErrorResume(e -> {
log.error("获取会员统计数据失败", e);
return Mono.just(MemberStatistics.builder().statDate(statDate).build());
});
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query)
.onErrorResume(e -> {
log.error("获取预约统计数据失败", e);
return Mono.just(BookingStatistics.builder().statDate(statDate).build());
});
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query)
.onErrorResume(e -> {
log.error("获取签到统计数据失败", e);
return Mono.just(SignInStatistics.builder().statDate(statDate).build());
});
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono) return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder() .map(tuple -> StatisticsSummary.builder()
@@ -207,75 +188,21 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} }
@Override @Override
public Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) { public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取) // 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query); String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> { .flatMapMany(json -> {
try { try {
List<DataStatistics> stats = objectMapper.readValue(json, java.util.List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(List.class, DataStatistics.class)); objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class));
return Flux.fromIterable(stats); return reactor.core.publisher.Flux.fromIterable(stats);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e); log.error("Failed to parse historical statistics from cache", e);
return Flux.empty(); return reactor.core.publisher.Flux.empty();
} }
}) })
.switchIfEmpty(buildLiveStatistics(query)); .switchIfEmpty(reactor.core.publisher.Flux.empty());
}
/**
* 缓存未命中时,从数据库实时构建统计数据
*/
private Flux<DataStatistics> buildLiveStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
String periodType = query.getPeriodType() != null ? query.getPeriodType() : "DAY";
LocalDateTime statDate = endTime;
Mono<Long> memberCountMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> bookingCountMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> signInCountMono = dataStatisticsDao.countSignIns(startTime, endTime);
return Mono.zip(memberCountMono, totalMembersMono, bookingCountMono, signInCountMono)
.flatMapMany(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long bookings = tuple.getT3() != null ? tuple.getT3() : 0L;
long signIns = tuple.getT4() != null ? tuple.getT4() : 0L;
List<DataStatistics> list = new ArrayList<>();
DataStatistics memberStat = DataStatistics.builder()
.statType(DataStatistics.StatType.MEMBER)
.periodType(periodType)
.statDate(statDate)
.count(totalMembers)
.extraData("新增" + newMembers + "")
.build();
list.add(memberStat);
DataStatistics bookingStat = DataStatistics.builder()
.statType(DataStatistics.StatType.BOOKING)
.periodType(periodType)
.statDate(statDate)
.count(bookings)
.extraData("新增" + bookings + "条预约")
.build();
list.add(bookingStat);
DataStatistics signInStat = DataStatistics.builder()
.statType(DataStatistics.StatType.SIGN_IN)
.periodType(periodType)
.statDate(statDate)
.count(signIns)
.extraData("新增" + signIns + "次签到")
.build();
list.add(signInStat);
return Flux.fromIterable(list);
});
} }
@Override @Override
@@ -531,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周一
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(); return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月第一天
return today.withDayOfMonth(1).atStartOfDay(); return today.withDayOfMonth(1).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
return today.minusDays(29).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
return today.minusDays(89).atStartOfDay();
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
return today.withDayOfYear(1).atStartOfDay();
} else { } else {
// 日统计:当天零点
return today.atStartOfDay(); return today.atStartOfDay();
} }
} }
@@ -551,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周日 23:59:59
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59); return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月最后一天 23:59:59
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
return LocalDateTime.now();
} else { } else {
// 日统计:当前时间
return LocalDateTime.now(); return LocalDateTime.now();
} }
} }
@@ -576,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
startTime = date.withDayOfMonth(1).atStartOfDay(); startTime = date.withDayOfMonth(1).atStartOfDay();
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
startTime = date.minusDays(29).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
startTime = date.minusDays(89).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
startTime = date.withDayOfYear(1).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else { } else {
startTime = date.atStartOfDay(); startTime = date.atStartOfDay();
endTime = date.plusDays(1).atStartOfDay(); endTime = date.plusDays(1).atStartOfDay();
@@ -1,34 +0,0 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Mono<BannerEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<BannerEntity> findAllByDeletedAtIsNull();
Flux<BannerEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE banner SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE banner SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.groupcourse.dao; package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -40,18 +41,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course SET deleted_at = NULL, status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NOT NULL")
Mono<Integer> restoreCourse(Long id, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt); Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> renewRecurringCourses(LocalDateTime updatedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType); Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
/** /**
@@ -100,11 +93,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
// 5. 常态化团课筛选
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序 // 5. 价格排序 / 6. 剩余名额最多排序
@@ -154,9 +142,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
spec = spec.bind("limit", size); spec = spec.bind("limit", size);
spec = spec.bind("offset", offset); spec = spec.bind("offset", offset);
@@ -181,7 +166,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
entity.setCreatedAt(row.get("created_at", LocalDateTime.class)); entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class)); entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class)); entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
return entity; return entity;
}).all(); }).all();
} }
@@ -224,10 +208,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString()); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
@@ -244,8 +224,99 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring()); return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
/**
* 分页查询团课(支持 keyword 和 status 过滤)
*/
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
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<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
spec = spec.bind("status", pageRequest.getStatus());
} }
return spec.map((row, meta) -> row.get(0, Long.class)).one(); return spec.map((row, meta) -> row.get(0, Long.class)).one();
@@ -29,8 +29,4 @@ public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntit
@Modifying @Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_type SET type_name = :typeName, base_difficulty = :baseDifficulty, description = :description, category = :category, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateFields(Long id, String typeName, Integer baseDifficulty, String description, String category, LocalDateTime updatedAt);
} }
@@ -1,43 +0,0 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class Banner extends BaseDomain {
@Schema(description = "背景图URL", example = "https://example.com/banner.jpg")
private String imageUrl;
@Schema(description = "主标题", example = "突破自我")
private String title;
@Schema(description = "副标题", example = "超越极限")
private String subtitle;
@Schema(description = "简介", example = "科学训练 · 遇见更好的自己")
private String description;
@Schema(description = "排序(数值越大越靠前)", example = "10")
private Integer sortOrder;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -60,10 +60,6 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png") @Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Schema(description = "是否常态化团课", example = "true")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -167,12 +163,4 @@ public class GroupCourse extends BaseDomain{
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -53,10 +53,6 @@ public class GroupCourseBooking extends BaseDomain {
@Schema(description = "上课地点", example = "健身房A区") @Schema(description = "上课地点", example = "健身房A区")
private String location; private String location;
//封面图URL(非DB字段,由 Service 从 GroupCourse 填充)
@Schema(description = "封面图URL", example = "https://example.com/cover.jpg")
private String coverImage;
public Long getCourseId() { public Long getCourseId() {
return courseId; return courseId;
} }
@@ -136,12 +132,4 @@ public class GroupCourseBooking extends BaseDomain {
public void setLocation(String location) { public void setLocation(String location) {
this.location = location; this.location = location;
} }
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
} }
@@ -40,9 +40,6 @@ public class GroupCourseQueryDto {
@Schema(description = "每页大小", example = "10") @Schema(description = "每页大小", example = "10")
private Integer size = 10; private Integer size = 10;
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
private Boolean isRecurring;
// ===== Getters and Setters ===== // ===== Getters and Setters =====
public String getCourseName() { public String getCourseName() {
@@ -116,12 +113,4 @@ public class GroupCourseQueryDto {
public void setSize(Integer size) { public void setSize(Integer size) {
this.size = size; this.size = size;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -1,45 +0,0 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("banner")
public class BannerEntity extends BaseEntity {
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("description")
private String description;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -62,10 +62,6 @@ public class GroupCourseEntity extends BaseEntity {
@Column("qr_code_path") @Column("qr_code_path")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Column("is_recurring")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -169,12 +165,4 @@ public class GroupCourseEntity extends BaseEntity {
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -1,224 +0,0 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "轮播图管理", description = "轮播图相关操作")
public class BannerHandler {
private final IBannerService bannerService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public BannerHandler(IBannerService bannerService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.bannerService = bannerService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序")
public Mono<ServerResponse> getAllBanners(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("sortOrder");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(bannerService.findAll(sortBy, sortOrder), Banner.class);
}
@Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表")
public Mono<ServerResponse> getAllActiveBanners(ServerRequest request) {
return ServerResponse.ok()
.body(bannerService.findAllActive(), Banner.class);
}
@Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情")
public Mono<ServerResponse> getBannerById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.findById(id)
.flatMap(banner -> ServerResponse.ok().bodyValue(banner))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建轮播图", description = "创建新的轮播图记录")
public Mono<ServerResponse> createBanner(ServerRequest request) {
return request.bodyToMono(Banner.class)
.flatMap(banner -> {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "背景图不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "主标题不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return bannerService.create(banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新轮播图", description = "更新指定轮播图信息,需验证管理员密码")
public Mono<ServerResponse> updateBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(Banner.class)
.flatMap(banner -> bannerService.update(id, banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除轮播图", description = "删除指定轮播图(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "启用轮播图", description = "启用指定轮播图")
public Mono<ServerResponse> enableBanner(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "禁用轮播图", description = "禁用指定轮播图,需验证管理员密码")
public Mono<ServerResponse> disableBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -1,120 +0,0 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 通用文件上传处理器
*/
@Slf4j
@Component
@Tag(name = "文件上传", description = "通用文件上传到阿里云OSS")
public class CommonUploadHandler {
@Operation(summary = "上传图片文件", description = "上传图片到阿里云OSS,返回ossKey和预签名URL")
public Mono<ServerResponse> uploadImage(ServerRequest request) {
return request.multipartData()
.flatMap(multiValueMap -> {
List<Part> fileParts = multiValueMap.get("file");
if (fileParts == null || fileParts.isEmpty()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "未选择文件"));
}
Part part = fileParts.get(0);
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "文件格式不正确"));
}
String originalFilename = filePart.filename();
String ext = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
ext = originalFilename.substring(dotIndex);
}
String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;
Path tempFile = null;
try {
tempFile = Files.createTempFile("upload-", newFileName);
} catch (Exception e) {
return Mono.error(new RuntimeException("创建临时文件失败", e));
}
Path finalTempFile = tempFile;
return filePart.transferTo(finalTempFile)
.then(Mono.defer(() -> {
try {
String ossKey;
try (InputStream inputStream = Files.newInputStream(finalTempFile)) {
ossKey = OSSUtil.uploadCoverToOSS(inputStream, newFileName);
}
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"message", "上传成功",
"data", Map.of(
"ossKey", ossKey,
"presignedUrl", presignedUrl,
"fileName", originalFilename
)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "上传失败: " + e.getMessage()));
} finally {
try {
Files.deleteIfExists(finalTempFile);
} catch (Exception ignored) {
}
}
}));
});
}
@Operation(summary = "获取OSS预签名URL", description = "根据ossKey生成临时访问URL,有效期5分钟")
public Mono<ServerResponse> presignUrl(ServerRequest request) {
String ossKey = request.queryParam("key").orElse(null);
if (ossKey == null || ossKey.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "参数 key 不能为空"));
}
try {
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"data", Map.of("presignedUrl", presignedUrl)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "生成预签名URL失败: " + e.getMessage()));
}
}
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.groupcourse.handler; package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService; import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -29,6 +30,13 @@ public class CourseLabelHandler {
.body(courseLabelService.findAll(), CourseLabel.class); .body(courseLabelService.findAll(), CourseLabel.class);
} }
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
}
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情") @Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) { public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
@@ -146,24 +154,17 @@ public class CourseLabelHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
Object labelIdsObj = body.get("labelIds"); @SuppressWarnings("unchecked")
List<?> rawIds = (List<?>) body.get("labelIds");
if (!(labelIdsObj instanceof List)) {
if (rawIds == null || rawIds.isEmpty()) {
Map<String, Object> error = new HashMap<>(); Map<String, Object> error = new HashMap<>();
error.put("success", false); error.put("success", false);
error.put("message", "labelIds不能为空"); error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error); return ServerResponse.badRequest().bodyValue(error);
} }
List<?> rawList = (List<?>) labelIdsObj; List<Long> labelIds = rawIds.stream()
if (rawList.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawList.stream()
.map(id -> Long.valueOf(String.valueOf(id))) .map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
@@ -177,31 +177,4 @@ public class GroupCourseBookingHandler {
response.put("message", message); response.put("message", message);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
/**
* 扫码签到(二维码扫一扫签到)
* 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席)
*/
@Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席")
public Mono<ServerResponse> qrSignIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID");
}
Long memberId = toLong(body.get("memberId"), "memberId");
return bookingService.qrSignIn(courseId, memberId)
.flatMap(booking -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "签到成功");
response.put("data", booking);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> buildErrorResponse(error.getMessage()));
});
}
} }
@@ -7,25 +7,15 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -36,21 +26,15 @@ public class GroupCourseHandler {
private final Validator validator; private final Validator validator;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseHandler(IGroupCourseService groupCourseService, public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator, Validator validator,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper){
ISysUserService sysUserService,
AuthUtil authUtil){
this.groupCourseService = groupCourseService; this.groupCourseService = groupCourseService;
this.validator = validator; this.validator = validator;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表") @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
@@ -130,43 +114,25 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "更新团课", description = "更新指定团课信息,需验证管理员密码") @Operation(summary = "更新团课", description = "更新指定团课信息")
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) { public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourse.class)
if (adminPassword == null || adminPassword.isBlank()) { .flatMap(groupCourse -> {
Map<String, Object> error = new HashMap<>(); return groupCourseService.update(id, groupCourse)
error.put("success", false); .flatMap(course -> {
error.put("message", "管理员密码不能为空"); Map<String, Object> response = new HashMap<>();
return ServerResponse.badRequest().bodyValue(error); response.put("success", true);
} response.put("message", "团课更新成功");
response.put("data", course);
return sysUserService.verifyPassword(adminId, adminPassword) return ServerResponse.ok().bodyValue(response);
.flatMap(valid -> { })
if (!valid) { .onErrorResume(error -> {
Map<String, Object> error = new HashMap<>(); Map<String, Object> response = new HashMap<>();
error.put("success", false); response.put("success", false);
error.put("message", "管理员密码错误"); response.put("message", error.getMessage());
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error); return ServerResponse.badRequest().bodyValue(response);
}
return request.bodyToMono(GroupCourse.class)
.flatMap(groupCourse -> {
return groupCourseService.update(id, groupCourse)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课更新成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
}); });
} }
@@ -231,78 +197,22 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "删除团课", description = "删除指定团课(软删除),需验证管理员密码") @Operation(summary = "删除团课", description = "删除指定团课(软删除)")
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) { public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return groupCourseService.delete(id)
if (adminPassword == null || adminPassword.isBlank()) { .then(Mono.defer(() -> {
Map<String, Object> error = new HashMap<>(); Map<String, Object> response = new HashMap<>();
error.put("success", false); response.put("success", true);
error.put("message", "管理员密码不能为空"); response.put("message", "团课删除成功");
return ServerResponse.badRequest().bodyValue(error); return ServerResponse.ok().bodyValue(response);
} }))
.onErrorResume(error -> {
return sysUserService.verifyPassword(adminId, adminPassword) Map<String, Object> response = new HashMap<>();
.flatMap(valid -> { response.put("success", false);
if (!valid) { response.put("message", error.getMessage());
Map<String, Object> error = new HashMap<>(); return ServerResponse.badRequest().bodyValue(response);
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "恢复已删除团课", description = "将已删除的团课恢复为已取消状态,需验证管理员密码")
public Mono<ServerResponse> restoreGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.restore(id)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课恢复成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
} }
@@ -379,44 +289,4 @@ public class GroupCourseHandler {
}); });
}); });
} }
@Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)")
public Mono<ServerResponse> getCourseQRCode(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("id"));
return groupCourseService.findById(courseId)
.flatMap(course -> {
return Mono.fromCallable(() -> {
String qrContent = "{\"courseId\":" + course.getId()
+ ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}";
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);
byte[] pngBytes = outputStream.toByteArray();
String base64 = Base64.getEncoder().encodeToString(pngBytes);
Map<String, Object> result = new HashMap<>();
result.put("base64", "data:image/png;base64," + base64);
result.put("courseId", course.getId());
result.put("courseName", course.getCourseName());
result.put("width", 300);
result.put("height", 300);
return result;
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
})
.flatMap(result -> ServerResponse.ok().bodyValue(result))
.switchIfEmpty(ServerResponse.notFound().build());
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
} }
@@ -2,11 +2,8 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -20,15 +17,9 @@ import java.util.Map;
public class GroupCourseRecommendHandler { public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService; private final IGroupCourseRecommendService recommendService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService, public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
ISysUserService sysUserService,
AuthUtil authUtil) {
this.recommendService = recommendService; this.recommendService = recommendService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序") @Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
@@ -89,73 +80,20 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码") @Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) { public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) { return request.bodyToMono(GroupCourseRecommend.class)
Map<String, Object> error = new HashMap<>(); .flatMap(recommend -> {
error.put("success", false); return recommendService.update(id, recommend)
error.put("message", "管理员密码不能为空"); .flatMap(r -> {
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> recommendService.update(id, recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
response.put("message", "团课推荐删除成功"); response.put("message", "团课推荐更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response); return ServerResponse.ok().bodyValue(response);
})) })
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
@@ -165,6 +103,25 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐") @Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
public Mono<ServerResponse> enableRecommendation(ServerRequest request) { public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
@@ -185,41 +142,23 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码") @Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) { public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) { return recommendService.disable(id)
Map<String, Object> error = new HashMap<>(); .flatMap(r -> {
error.put("success", false); Map<String, Object> response = new HashMap<>();
error.put("message", "管理员密码不能为空"); response.put("success", true);
return ServerResponse.badRequest().bodyValue(error); response.put("message", "团课推荐禁用成功");
} response.put("data", r);
return ServerResponse.ok().bodyValue(response);
return sysUserService.verifyPassword(adminId, adminPassword) })
.flatMap(valid -> { .onErrorResume(error -> {
if (!valid) { Map<String, Object> response = new HashMap<>();
Map<String, Object> error = new HashMap<>(); response.put("success", false);
error.put("success", false); response.put("message", error.getMessage());
error.put("message", "管理员密码错误"); return ServerResponse.badRequest().bodyValue(response);
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
} }
} }
@@ -1,12 +1,10 @@
package cn.novalon.gym.manage.groupcourse.handler; package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -20,15 +18,9 @@ import java.util.Map;
public class GroupCourseTypeHandler { public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService; private final IGroupCourseTypeService groupCourseTypeService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService, public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) {
ISysUserService sysUserService,
AuthUtil authUtil) {
this.groupCourseTypeService = groupCourseTypeService; this.groupCourseTypeService = groupCourseTypeService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表") @Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
@@ -101,76 +93,21 @@ public class GroupCourseTypeHandler {
}); });
} }
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码") @Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) { public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourseType.class)
if (adminPassword == null || adminPassword.isBlank()) { .flatMap(groupCourseType -> {
Map<String, Object> error = new HashMap<>(); groupCourseType.setId(id);
error.put("success", false); return groupCourseTypeService.update(id, groupCourseType)
error.put("message", "管理员密码不能为空"); .flatMap(type -> {
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
groupCourseType.setId(id);
return groupCourseTypeService.update(id, groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型更新成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
});
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除),需验证管理员密码,且该类型不能被任何团课引用")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
response.put("message", "团课类型删除成功"); response.put("message", "团课类型更新成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response); return ServerResponse.ok().bodyValue(response);
})) })
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
@@ -179,4 +116,31 @@ public class GroupCourseTypeHandler {
}); });
}); });
} }
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response))
);
}
}
@@ -1,24 +0,0 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerRepository {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> save(Banner banner);
Mono<Banner> update(Banner banner);
Mono<Void> deleteById(Long id);
Mono<Banner> updateActiveStatus(Long id, Boolean isActive);
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository; package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,4 +31,6 @@ public interface ICourseLabelRepository {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -27,8 +27,6 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> restoreById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta); Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType); Flux<GroupCourse> findByCourseType(Long courseType);
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository; package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> update(GroupCourseType groupCourseType); Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -1,113 +0,0 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.BannerDao;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.bannerDao = bannerDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll() {
return bannerDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return bannerDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAllActive() {
return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder"))
.map(this::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return bannerDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<Banner> update(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(banner.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return bannerDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Banner> updateActiveStatus(Long id, Boolean isActive) {
return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private Banner toDomain(BannerEntity entity) {
if (entity == null) return null;
Banner banner = new Banner();
BeanUtil.copyProperties(entity, banner);
return banner;
}
private BannerEntity toEntity(Banner domain) {
if (domain == null) return null;
BannerEntity entity = new BannerEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao; import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao; import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
@@ -9,12 +11,17 @@ import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository; import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Repository @Repository
@@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository {
private final CourseLabelDao courseLabelDao; private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao; private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter; private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao, public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter) { GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.courseLabelDao = courseLabelDao; this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao; this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository {
} }
return entity; return entity;
} }
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<CourseLabel> list = tuple.getT1().stream()
.map(this::toCourseLabel)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
} }
@@ -97,37 +97,19 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) { public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
int size = pageRequest.getSize(); int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
Sort sortObj = Sort.unsorted(); return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
if (sort != null && !sort.isEmpty()) { .flatMap(total -> {
sortObj = Sort.by(Sort.Direction.fromString(order), sort); if (total == 0) {
} return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
.collectList()
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
.map(tuple -> {
List<GroupCourseEntity> allEntities = tuple.getT1();
long total = tuple.getT2();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, allEntities.size());
List<GroupCourse> courseList;
if (fromIndex < allEntities.size()) {
courseList = allEntities.subList(fromIndex, toIndex).stream()
.map(groupCourseConverter::toDomain)
.toList();
} else {
courseList = List.of();
} }
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
int totalPages = (int) Math.ceil((double) total / size); .map(groupCourseConverter::toDomain)
return new PageResponse<>(courseList, totalPages, total, page, size); .collectList()
.map(courseList -> {
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(courseList, totalPages, total, page, size);
});
}); });
} }
@@ -138,7 +120,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setStatus(0L); entity.setStatus(0L);
entity.setCurrentMembers(0); entity.setCurrentMembers(0);
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -148,7 +129,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<GroupCourse> update(GroupCourse groupCourse) { public Mono<GroupCourse> update(GroupCourse groupCourse) {
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse); GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return r2dbcEntityTemplate.update(entity) return r2dbcEntityTemplate.update(entity)
.then(findByIdAndDeletedAtIsNull(groupCourse.getId())); .then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
@@ -171,17 +151,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then(); .then();
} }
@Override
public Mono<GroupCourse> restoreById(Long id) {
return groupCourseDao.restoreCourse(id, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.error(new RuntimeException("团课恢复失败,可能该课程未被删除"));
});
}
@Override @Override
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) { public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now()) return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao; import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
@@ -7,12 +9,18 @@ import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository @Repository
@Transactional @Transactional
@@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private final GroupCourseTypeDao groupCourseTypeDao; private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter; private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) { public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter,
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.groupCourseTypeDao = groupCourseTypeDao; this.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -105,20 +116,21 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()) return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> { .flatMap(existing -> {
String typeName = groupCourseType.getTypeName() != null existing.markNotNew();
? groupCourseType.getTypeName() : existing.getTypeName(); if (groupCourseType.getTypeName() != null) {
Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null existing.setTypeName(groupCourseType.getTypeName());
? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty(); }
String description = groupCourseType.getDescription() != null if (groupCourseType.getBaseDifficulty() != null) {
? groupCourseType.getDescription() : existing.getDescription(); existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
String category = groupCourseType.getCategory() != null }
? groupCourseType.getCategory() : existing.getCategory(); if (groupCourseType.getDescription() != null) {
LocalDateTime now = LocalDateTime.now(); existing.setDescription(groupCourseType.getDescription());
}
return groupCourseTypeDao.updateFields( if (groupCourseType.getCategory() != null) {
groupCourseType.getId(), typeName, baseDifficulty, existing.setCategory(groupCourseType.getCategory());
description, category, now) }
.then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())); existing.setUpdatedAt(LocalDateTime.now());
return groupCourseTypeDao.save(existing);
}) })
.map(converter::toGroupCourseType); .map(converter::toGroupCourseType);
} }
@@ -128,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now()) return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then(); .then();
} }
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String order = pageRequest.getOrder();
String sort = pageRequest.getSort();
String keyword = pageRequest.getKeyword();
String category = pageRequest.getCategory();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
// Build dynamic criteria
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
}
if (category != null && !category.isEmpty()) {
criteriaList.add(Criteria.where("category").is(category));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<GroupCourseType> list = tuple.getT1().stream()
.map(converter::toGroupCourseType)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
} }
@@ -1,48 +0,0 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 常态化团课自动续期定时任务
*
* 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'),
* 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。
*
* @date 2026-06-29
*/
@Component
public class GroupCourseRecurringScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已结束的常态化团课重置为正常状态,
* 并将上课时间/下课时间各推迟一周
*/
@Scheduled(fixedRate = 60000)
public void renewRecurringCourses() {
logger.debug("定时任务开始检查常态化团课,续期已结束的课程");
groupCourseDao.renewRecurringCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("常态化团课续期完成,更新了 {} 条课程", count);
}
},
error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -1,26 +0,0 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerService {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> create(Banner banner);
Mono<Banner> update(Long id, Banner banner);
Mono<Void> delete(Long id);
Mono<Banner> enable(Long id);
Mono<Banner> disable(Long id);
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service; package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -27,4 +29,6 @@ public interface ICourseLabelService {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -62,14 +62,4 @@ public interface IGroupCourseBookingService {
* @return 处理的记录数 * @return 处理的记录数
*/ */
Mono<Integer> processAbsentMembers(); Mono<Integer> processAbsentMembers();
/**
* 扫码签到
* 用户扫描团课二维码签到,将预约状态更新为已出席(2)
*
* @param courseId 团课ID
* @param memberId 会员ID
* @return 更新后的预约记录
*/
Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId);
} }
@@ -26,8 +26,6 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId); Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id); Mono<Void> delete(Long id);
Mono<GroupCourse> restore(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query); Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service; package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
* @return 分类名称列表 * @return 分类名称列表
*/ */
Flux<String> findCategories(); Flux<String> findCategories();
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -1,99 +0,0 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class BannerService implements IBannerService {
private static final Logger logger = LoggerFactory.getLogger(BannerService.class);
private final IBannerRepository bannerRepository;
public BannerService(IBannerRepository bannerRepository) {
this.bannerRepository = bannerRepository;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerRepository.findById(id);
}
@Override
public Flux<Banner> findAll() {
return bannerRepository.findAll();
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
return bannerRepository.findAll(sortBy, sortOrder);
}
@Override
public Flux<Banner> findAllActive() {
return bannerRepository.findAllActive();
}
@Override
public Mono<Banner> create(Banner banner) {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
return Mono.error(new RuntimeException("背景图不能为空"));
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
return Mono.error(new RuntimeException("主标题不能为空"));
}
return bannerRepository.save(banner)
.doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle()))
.doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<Banner> update(Long id, Banner banner) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(existing -> {
if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl());
if (banner.getTitle() != null) existing.setTitle(banner.getTitle());
if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle());
if (banner.getDescription() != null) existing.setDescription(banner.getDescription());
if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder());
if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive());
return bannerRepository.update(existing);
})
.doOnSuccess(r -> logger.info("轮播图更新成功 - id={}", id))
.doOnError(error -> logger.error("轮播图更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(banner -> bannerRepository.deleteById(id)
.doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id))
.doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage())));
}
@Override
public Mono<Banner> enable(Long id) {
return bannerRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图启用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Banner> disable(Long id) {
return bannerRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage()));
}
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service.impl; package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
@@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService {
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId)) .doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage())); .doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
} }
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
return courseLabelRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
}
} }
@@ -7,10 +7,8 @@ import cn.novalon.gym.manage.groupcourse.handler.BookingSagaHandler;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -48,7 +46,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final GroupCourseRedisService redisService; private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler; private final BookingSagaHandler bookingSagaHandler;
private final DatabaseClient databaseClient;
// 预约提前时间限制(分钟) // 预约提前时间限制(分钟)
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -59,14 +56,12 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
IGroupCourseRepository courseRepository, IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService, GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher, BookingReminderEventPublisher bookingReminderEventPublisher,
BookingSagaHandler bookingSagaHandler, BookingSagaHandler bookingSagaHandler) {
DatabaseClient databaseClient) {
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.courseRepository = courseRepository; this.courseRepository = courseRepository;
this.redisService = redisService; this.redisService = redisService;
this.bookingReminderEventPublisher = bookingReminderEventPublisher; this.bookingReminderEventPublisher = bookingReminderEventPublisher;
this.bookingSagaHandler = bookingSagaHandler; this.bookingSagaHandler = bookingSagaHandler;
this.databaseClient = databaseClient;
} }
@Override @Override
@@ -336,18 +331,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) { public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
logger.debug("查询会员预约记录:memberId={}", memberId); logger.debug("查询会员预约记录:memberId={}", memberId);
return bookingRepository.findByMemberId(memberId) return bookingRepository.findByMemberId(memberId)
.flatMap(booking -> {
// 从关联的 GroupCourse 获取封面图并转为预签名URL
if (booking.getCourseId() != null) {
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
.map(course -> {
booking.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
return booking;
})
.defaultIfEmpty(booking);
}
return Mono.just(booking);
})
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId)); .doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
} }
@@ -364,78 +347,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
} }
@Override
public Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId) {
logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId);
return courseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> {
// 校验1:团课状态必须为 0(正常)
Long status = course.getStatus();
if (status == null || status != 0L) {
String msg;
if (status == null) msg = "课程状态异常";
else if (status == 1L) msg = "课程已取消,无法签到";
else if (status == 2L) msg = "课程已结束,无法签到";
else msg = "课程状态不可签到";
return Mono.error(new RuntimeException(msg));
}
// 校验2:用户是否预约了该课程(状态为 0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
// 校验3:预约状态必须为 0
if (!"0".equals(booking.getStatus())) {
String msg;
if ("1".equals(booking.getStatus())) msg = "预约已取消";
else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到";
else msg = "预约状态异常";
return Mono.error(new RuntimeException(msg));
}
// 更新预约状态为 2(已出席)
return bookingRepository.updateStatus(booking.getId(), "2")
.then(Mono.defer(() -> {
// 同步写入签到记录,供仪表盘统计
LocalDateTime now = LocalDateTime.now();
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 先检查今天是否已有成功签到记录,避免重复
return databaseClient.sql(
"SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1")
.bind("memberId", memberId)
.bind("startTime", todayStart)
.bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.flatMap(existing -> {
// 已有签到记录,不重复插入
return bookingRepository.findById(booking.getId());
})
.switchIfEmpty(
// 无今日签到记录,插入一条
databaseClient.sql(
"INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " +
"VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)")
.bind("memberId", memberId)
.bind("memberCardId", booking.getMemberCardRecordId())
.bind("signInTime", now)
.fetch()
.rowsUpdated()
.then(bookingRepository.findById(booking.getId()))
);
}));
});
})
.doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}",
booking.getId(), courseId, memberId))
.doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage()));
}
@Override @Override
public Mono<Integer> processAbsentMembers() { public Mono<Integer> processAbsentMembers() {
logger.info("开始处理已开始课程但未到场会员的预约记录"); logger.info("开始处理已开始课程但未到场会员的预约记录");
@@ -5,7 +5,6 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -135,8 +134,6 @@ public class GroupCourseRecommendService implements IGroupCourseRecommendService
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId()) return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> { .map(course -> {
// 将 OSS Key 转换为预签名URL,前端可直接加载
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
recommend.setGroupCourse(course); recommend.setGroupCourse(course);
return recommend; return recommend;
}) })
@@ -19,7 +19,6 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil; import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
@@ -33,7 +32,6 @@ import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap; import java.util.HashMap;
@@ -155,7 +153,7 @@ public class GroupCourseService implements IGroupCourseService {
detail.setCurrentMembers(course.getCurrentMembers()); detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus()); detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation()); detail.setLocation(course.getLocation());
detail.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage())); detail.setCoverImage(course.getCoverImage());
detail.setDescription(course.getDescription()); detail.setDescription(course.getDescription());
detail.setStoredValueAmount(course.getStoredValueAmount()); detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath()); detail.setQrCodePath(course.getQrCodePath());
@@ -180,7 +178,7 @@ public class GroupCourseService implements IGroupCourseService {
try { try {
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class); GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
logger.info("缓存命中 - findById: id={}", id); logger.info("缓存命中 - findById: id={}", id);
return Mono.just(fillCoverPresignedUrl(groupCourse)); return Mono.just(groupCourse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -190,7 +188,6 @@ public class GroupCourseService implements IGroupCourseService {
}) })
.switchIfEmpty( .switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id) groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.map(this::fillCoverPresignedUrl)
.flatMap(groupCourse -> { .flatMap(groupCourse -> {
try { try {
String jsonData = objectMapper.writeValueAsString(groupCourse); String jsonData = objectMapper.writeValueAsString(groupCourse);
@@ -208,19 +205,16 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Flux<GroupCourse> findAll() { public Flux<GroupCourse> findAll() {
return groupCourseRepository.findAll() return groupCourseRepository.findAll();
.map(this::fillCoverPresignedUrl);
} }
@Override @Override
public Flux<GroupCourse> findAll(boolean includeDeleted) { public Flux<GroupCourse> findAll(boolean includeDeleted) {
Flux<GroupCourse> flux;
if(includeDeleted){ if(includeDeleted){
flux = groupCourseRepository.findAll(); return groupCourseRepository.findAll();
}else{ }else{
flux = groupCourseRepository.findByDeletedAtIsNull(); return groupCourseRepository.findByDeletedAtIsNull();
} }
return flux.map(this::fillCoverPresignedUrl);
} }
@Override @Override
@@ -230,8 +224,9 @@ public class GroupCourseService implements IGroupCourseService {
String sort = pageRequest.getSort(); String sort = pageRequest.getSort();
String order = pageRequest.getOrder(); String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : ""; String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
@@ -240,7 +235,6 @@ public class GroupCourseService implements IGroupCourseService {
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson, PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class)); objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
logger.info("缓存命中 - findByPage: key={}", cacheKey); logger.info("缓存命中 - findByPage: key={}", cacheKey);
fillCoverPresignedUrl(pageResponse);
return Mono.just(pageResponse); return Mono.just(pageResponse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
@@ -261,7 +255,6 @@ public class GroupCourseService implements IGroupCourseService {
return resultMono.flatMap(pageResponse -> { return resultMono.flatMap(pageResponse -> {
try { try {
fillCoverPresignedUrl(pageResponse);
String jsonData = objectMapper.writeValueAsString(pageResponse); String jsonData = objectMapper.writeValueAsString(pageResponse);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS) return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(pageResponse) .thenReturn(pageResponse)
@@ -355,9 +348,6 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getQrCodePath() != null) { if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath()); existing.setQrCodePath(groupCourse.getQrCodePath());
} }
if (groupCourse.getIsRecurring() != null) {
existing.setIsRecurring(groupCourse.getIsRecurring());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -547,10 +537,11 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 已取消或已结束的课程才能删除 // 只有已取消或已结束的课程才能删除
return groupCourseRepository.findByIdAndDeletedAtIsNull(id) return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> { .flatMap(course -> {
// 检查课程状态是否为已取消(状态码1)或已结束(状态码2)
Long status = course.getStatus(); Long status = course.getStatus();
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) { if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " + return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
@@ -565,14 +556,6 @@ public class GroupCourseService implements IGroupCourseService {
}); });
} }
@Override
public Mono<GroupCourse> restore(Long id) {
return groupCourseRepository.restoreById(id)
.doOnSuccess(course -> logger.info("团课恢复成功 - id={}", id))
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课恢复失败 - id={}, error: {}", id, error.getMessage()));
}
@Override @Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) { public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}", logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
@@ -580,11 +563,8 @@ public class GroupCourseService implements IGroupCourseService {
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost()); query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query) return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> { .doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
fillCoverPresignedUrl(result); result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize());
})
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage())); .doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
} }
@@ -593,23 +573,4 @@ public class GroupCourseService implements IGroupCourseService {
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")) .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then(); .then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
} }
/**
* 将单个 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
*/
private GroupCourse fillCoverPresignedUrl(GroupCourse course) {
if (course != null) {
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
}
return course;
}
/**
* 将分页结果中所有 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
*/
private void fillCoverPresignedUrl(PageResponse<GroupCourse> pageResponse) {
if (pageResponse != null && pageResponse.getContent() != null) {
pageResponse.getContent().forEach(this::fillCoverPresignedUrl);
}
}
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service.impl; package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
@@ -19,7 +21,7 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository, public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) { IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository; this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
} }
@@ -72,16 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 检查是否有团课依赖该类型
return groupCourseRepository.findByCourseType(id) return groupCourseRepository.findByCourseType(id)
.hasElements() .hasElements()
.flatMap(hasCourses -> { .flatMap(hasDependents -> {
if (hasCourses) { if (hasDependents) {
return Mono.<Void>error(new RuntimeException("该类型下存在关联团课,无法删除")); return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
} }
return groupCourseTypeRepository.deleteById(id); return groupCourseTypeRepository.deleteById(id)
}) .doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) .doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); });
} }
@Override @Override
@@ -91,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
.filter(category -> category != null && !category.isEmpty()) .filter(category -> category != null && !category.isEmpty())
.distinct(); .distinct();
} }
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
return groupCourseTypeRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
}
} }
@@ -1,19 +1,14 @@
package cn.novalon.gym.manage.groupcourse.util; package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS; import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Date;
/** /**
* 阿里云OSS工具类 * 阿里云OSS工具类
@@ -23,9 +18,9 @@ public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class); private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息 // OSS配置信息
private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"; private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y"; private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq"; private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
private static final String BUCKET_NAME = "ycc-filesaver"; private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀 // OSS访问地址前缀
@@ -33,33 +28,36 @@ public class OSSUtil {
// 文件存储目录 // 文件存储目录
private static final String QRCODE_DIR = "qrcode/"; private static final String QRCODE_DIR = "qrcode/";
private static final String COVER_DIR = "cover/";
// 预签名URL有效期(秒)
private static final long PRESIGN_EXPIRE_SECONDS = 300;
// 封面图预签名URL有效期(秒)- 1小时,确保前端列表页足够展示
private static final long COVER_PRESIGN_EXPIRE_SECONDS = 3600;
/** /**
* 上传文件到阿里云OSS(文件默认继承Bucket权限,不设置ACL) * 上传文件到阿里云OSS
* *
* @param localFilePath 本地文件路径 * @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径) * @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀) * @return OSS访问地址
*/ */
public static String uploadToOSS(String localFilePath, String fileName) { public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null; OSS ossClient = null;
try { try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET); ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName; String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath)); PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest); ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath); // 构建访问地址
return ossFilePath; String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} catch (Exception e) { } catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e); logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e); throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
@@ -71,25 +69,34 @@ public class OSSUtil {
} }
/** /**
* 上传文件到阿里云OSS(自定义存储路径,文件默认继承Bucket权限 * 上传文件到阿里云OSS(自定义存储路径)
* *
* @param localFilePath 本地文件路径 * @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录 * @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径) * @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀) * @return OSS访问地址
*/ */
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) { public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null; OSS ossClient = null;
try { try {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET); ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
// 构建OSS文件路径
String ossFilePath = ossDirectory + fileName; String ossFilePath = ossDirectory + fileName;
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath)); PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
// 上传文件
ossClient.putObject(putObjectRequest); ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath); // 构建访问地址
return ossFilePath; String accessUrl = OSS_URL_PREFIX + ossFilePath;
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
return accessUrl;
} catch (Exception e) { } catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e); logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e); throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
@@ -99,95 +106,4 @@ public class OSSUtil {
} }
} }
} }
}
/**
* 上传封面图到阿里云OSS(使用InputStream,文件默认继承Bucket权限)
*
* @param inputStream 文件输入流
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadCoverToOSS(InputStream inputStream, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = COVER_DIR + datePath + "/" + fileName;
ossClient.putObject(BUCKET_NAME, ossFilePath, inputStream);
logger.info("封面上传到OSS成功: fileName={}, ossKey={}", fileName, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("封面上传到OSS失败 - fileName: {}, error: {}", fileName, e.getMessage(), e);
throw new RuntimeException("封面上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 生成OSS对象预签名URL(临时访问链接)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey) {
return generatePresignedUrl(ossKey, PRESIGN_EXPIRE_SECONDS);
}
/**
* 生成OSS对象预签名URL(可指定有效期)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @param expireSeconds 有效期(秒)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey, long expireSeconds) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(BUCKET_NAME, ossKey, HttpMethod.GET);
request.setExpiration(expiration);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
} catch (Exception e) {
logger.error("生成预签名URL失败 - ossKey: {}, error: {}", ossKey, e.getMessage(), e);
throw new RuntimeException("生成预签名URL失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 根据OSS Key拼接公开访问URL(仅当Bucket为公共读时有效)
*/
public static String getPublicUrl(String ossKey) {
return OSS_URL_PREFIX + ossKey;
}
/**
* 将OSS Key(相对路径)转换为封面图预签名URL(1小时有效期)
* 如果已经是HTTP(S)完整URL则直接返回
*
* @param ossKey OSS对象Key或完整URL
* @return 可访问的预签名URLossKey为空时返回null
*/
public static String toCoverPresignedUrl(String ossKey) {
if (ossKey == null || ossKey.isBlank()) {
return null;
}
if (ossKey.startsWith("http://") || ossKey.startsWith("https://")) {
return ossKey;
}
return generatePresignedUrl(ossKey, COVER_PRESIGN_EXPIRE_SECONDS);
}
}
@@ -61,7 +61,9 @@ class QRCodeUtilTest {
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent); String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
assertNotNull(ossUrl, "OSS访问地址不应为空"); assertNotNull(ossUrl, "OSS访问地址不应为空");
assertTrue(ossUrl.startsWith("qrcode/"), "OSS访问地址应以qrcode/开头"); assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式"); assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl); System.out.println("上传到OSS的二维码地址: " + ossUrl);
@@ -1,59 +0,0 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
/**
* 管理员编辑会员信息DTO(含密码验证)
*
* @author system
* @date 2026-06-24
*/
@Data
public class AdminEditMemberDto {
/**
* 管理员密码(必填,用于验证身份)
*/
private String adminPassword;
/**
* 昵称
*/
private String nickname;
/**
* 性别
*/
private GenderEnum gender;
/**
* 生日
*/
private LocalDate birthday;
/**
* 地址
*/
private String address;
/**
* 头像
*/
private String avatar;
/**
* 转换为 UpdateMemberInfoDto
*/
public UpdateMemberInfoDto toUpdateMemberInfoDto() {
UpdateMemberInfoDto dto = new UpdateMemberInfoDto();
dto.setNickname(nickname);
dto.setGender(gender);
dto.setBirthday(birthday);
dto.setAddress(address);
dto.setAvatar(avatar);
return dto;
}
}
@@ -2,21 +2,16 @@ package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员卡管理处理器 * 会员卡管理处理器
* *
@@ -29,13 +24,9 @@ import java.util.Map;
public class MemberCardHandler { public class MemberCardHandler {
private final IMemberCardService memberCardService; private final IMemberCardService memberCardService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public MemberCardHandler(IMemberCardService memberCardService, ISysUserService sysUserService, AuthUtil authUtil) { public MemberCardHandler(IMemberCardService memberCardService) {
this.memberCardService = memberCardService; this.memberCardService = memberCardService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情") @Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
@@ -69,72 +60,38 @@ public class MemberCardHandler {
.flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card)); .flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card));
} }
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息,需验证管理员密码") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null); return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
if (adminPassword == null || adminPassword.isBlank()) { .zipWith(request.bodyToMono(MemberCard.class))
return ServerResponse.badRequest() .flatMap(tuple -> {
.contentType(MediaType.APPLICATION_JSON) MemberCard existing = tuple.getT1();
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空")) MemberCard body = tuple.getT2();
.flatMap(resp -> Mono.just(resp)); // 仅更新非 null 字段,避免 null 覆盖已有值
} if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
return sysUserService.verifyPassword(adminId, adminPassword) if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
.flatMap(valid -> { if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
if (!valid) { if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
return ServerResponse.status(HttpStatus.FORBIDDEN) if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
.contentType(MediaType.APPLICATION_JSON) if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
.bodyValue(Map.of("code", 403, "message", "管理员密码错误")) if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
.flatMap(resp -> Mono.just(resp)); return memberCardService.save(existing);
} })
return request.bodyToMono(MemberCard.class) .flatMap(updated -> ServerResponse.ok().bodyValue(updated));
.flatMap(body -> memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(existing -> {
existing.setMemberCardName(body.getMemberCardName());
existing.setMemberCardType(body.getMemberCardType());
existing.setMemberCardPrice(body.getMemberCardPrice());
existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
existing.setMemberCardAmount(body.getMemberCardAmount());
existing.setMemberCardStatus(body.getMemberCardStatus());
return memberCardService.save(existing);
})
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build()));
});
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型,需验证管理员密码") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
public Mono<ServerResponse> deleteMemberCard(ServerRequest request) { public Mono<ServerResponse> deleteMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null); return memberCardService.logicalDelete(id)
.flatMap(rows -> {
if (adminPassword == null || adminPassword.isBlank()) { if (rows > 0) {
return ServerResponse.badRequest() return ServerResponse.noContent().build();
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
} }
return memberCardService.logicalDelete(id) return ServerResponse.notFound().build();
.flatMap(rows -> {
if (rows > 0) {
return ServerResponse.noContent().build();
}
return ServerResponse.notFound().build();
});
}); });
} }
@@ -1,7 +1,7 @@
package cn.novalon.gym.manage.member.handler; package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.common.exception.NotFoundException; import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.dto.AdminEditMemberDto; import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto; import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
@@ -10,8 +10,8 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.service.WechatOfficialService; import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil; import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -24,8 +24,6 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员信息处理器 * 会员信息处理器
* *
@@ -43,7 +41,6 @@ public class MemberHandler {
private final WechatAuthService wechatAuthService; private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService; private final WechatOfficialService wechatOfficialService;
private final AuthUtil authUtil; private final AuthUtil authUtil;
private final ISysUserService sysUserService;
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) { public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
@@ -158,8 +155,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(detail.getPhone()); String decryptedPhone = AesUtil.decrypt(detail.getPhone());
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", detail.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
detail.setPhone(null); detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
} }
} }
return ServerResponse.ok() return ServerResponse.ok()
@@ -168,7 +165,7 @@ public class MemberHandler {
}); });
} }
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息,需验证管理员密码") @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getMemberIdOrThrow(request);
@@ -177,30 +174,14 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误"); if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId); log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
return request.bodyToMono(AdminEditMemberDto.class) return request.bodyToMono(UpdateMemberInfoDto.class)
.flatMap(dto -> { .flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
if (dto.getAdminPassword() == null || dto.getAdminPassword().isBlank()) { .flatMap(detail -> ServerResponse.ok()
return ServerResponse.badRequest() .contentType(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON) .bodyValue(detail));
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return sysUserService.verifyPassword(adminId, dto.getAdminPassword())
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return memberService.adminUpdateMemberInfo(memberId, dto.toUpdateMemberInfoDto())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
});
});
} }
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
@@ -223,8 +204,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -241,11 +222,13 @@ public class MemberHandler {
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
String sortField = request.queryParam("sortField").orElse(null);
String sortOrder = request.queryParam("sortOrder").orElse(null);
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize); log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
// TODO: 补充签到记录 adminId, pageNum, pageSize, sortField, sortOrder);
return memberService.findAll(pageNum, pageSize) return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
.map(member -> { .map(member -> {
// 解密手机号 // 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) { if (member.getPhone() != null && !member.getPhone().isEmpty()) {
@@ -253,8 +236,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
"AND (:type IS NULL OR member_card_type = :type) " + "AND (:type IS NULL OR member_card_type = :type) " +
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " + "AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
"ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}") "ORDER BY id ASC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
Flux<MemberCard> findWithConditions(Integer status, String name, String type, Flux<MemberCard> findWithConditions(Integer status, String name, String type,
Double minPrice, Double maxPrice, Pageable pageable); Double minPrice, Double maxPrice, Pageable pageable);
@@ -58,7 +58,7 @@ public interface MemberService {
* @param pageSize 页大小 * @param pageSize 页大小
* @return 所有会员信息 * @return 所有会员信息
*/ */
Flux<Member> findAll(Integer pageNum, Integer pageSize); Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
/** /**
* 前台管理端获取会员详情(含会员卡信息) * 前台管理端获取会员详情(含会员卡信息)
@@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -230,13 +231,31 @@ public class MemberServiceImpl implements MemberService {
); );
} }
/** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */
private static final java.util.Map<String, String> ALLOWED_SORT_FIELDS = java.util.Map.of(
"createdAt", "created_at",
"memberNo", "member_no",
"gender", "gender",
"id", "id"
);
@Override @Override
public Flux<Member> findAll(Integer pageNum, Integer pageSize) { public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
pageNum, pageSize, sortField, sortOrder);
// 排序方向:desc(默认) / asc
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC : Sort.Direction.DESC;
// 排序字段白名单校验,不在白名单中则使用默认排序
String dbField = (sortField != null && ALLOWED_SORT_FIELDS.containsKey(sortField))
? ALLOWED_SORT_FIELDS.get(sortField) : "created_at";
Pageable pageable = PageRequest.of( Pageable pageable = PageRequest.of(
pageNum - 1, pageNum - 1,
pageSize pageSize,
Sort.by(direction, dbField)
); );
return memberRepository.findAllBy(pageable); return memberRepository.findAllBy(pageable);
@@ -249,63 +268,51 @@ public class MemberServiceImpl implements MemberService {
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
return redisUtil.get(cacheKey, MemberDetailVO.class) return redisUtil.get(cacheKey, MemberDetailVO.class)
.flatMap(cached -> { .filter(cached -> cached != null)
if (cached != null) { .switchIfEmpty(Mono.defer(() ->
log.debug("从缓存获取会员详情, memberId: {}", memberId); memberRepository.findById(memberId)
return Mono.just(cached); .switchIfEmpty(Mono.error(() -> {
} log.error("会员不存在: memberId={}", memberId);
// 缓存反序列化异常,查数据库 throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
return queryMemberDetailFromDb(memberId, cacheKey); }))
}) .zipWith(
.switchIfEmpty(Mono.defer(() -> { memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
// 缓存不存在,查数据库 .collectList(),
return queryMemberDetailFromDb(memberId, cacheKey); (baseInfo, cardList) -> {
})); MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
}
private Mono<MemberDetailVO> queryMemberDetailFromDb(Long memberId, String cacheKey) { GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
return memberRepository.findById(memberId) memberDetailVO.setGenderDesc(genderEnum.getDesc());
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); List<MemberCardInfoVO> enrichedCards = cardList.stream()
memberDetailVO.setGenderDesc(genderEnum.getDesc()); .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);
List<MemberCardInfoVO> enrichedCards = cardList.stream() long activeCount = enrichedCards.stream()
.peek(vo -> { .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
if (vo.getMemberCardType() != null) { .count();
try { memberDetailVO.setActiveCardCount((int) activeCount);
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType()); memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) { return memberDetailVO;
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
} }
if (vo.getMemberCardStatus() != null) { )
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架"); .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
} .then(Mono.just(vo)))
}) ));
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
long activeCount = enrichedCards.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
.count();
memberDetailVO.setActiveCardCount((int) activeCount);
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
return memberDetailVO;
}
)
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo)))
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
return new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
} }
@@ -1,54 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付记录响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "支付记录")
public class PaymentRecordResponse {
@Schema(description = "订单ID")
private Long id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "用户编号(会员ID)")
private Long memberId;
@Schema(description = "支付方式(ALIPAY/WECHAT)")
private String tradeType;
@Schema(description = "购买内容(商品描述)")
private String goodsDesc;
@Schema(description = "订单类型(MEMBER_CARD/GROUP_COURSE/GOODS)")
private String orderType;
@Schema(description = "支付金额(元)")
private BigDecimal transAmt;
@Schema(description = "支付状态(PENDING/SUCCESS/FAIL/CLOSED)")
private String payStatus;
@Schema(description = "支付时间")
private String payTime;
@Schema(description = "汇付流水号")
private String hfSeqId;
@Schema(description = "创建时间")
private String createdAt;
}
@@ -1,47 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 营业额统计数据
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "营业额统计数据")
public class RevenueStatistics {
@Schema(description = "今日收入(元)")
private BigDecimal todayIncome;
@Schema(description = "今日退款(元)")
private BigDecimal todayRefund;
@Schema(description = "今日订单数")
private Long todayOrderCount;
@Schema(description = "当月收入(元)")
private BigDecimal monthIncome;
@Schema(description = "当月退款(元)")
private BigDecimal monthRefund;
@Schema(description = "当月订单数")
private Long monthOrderCount;
@Schema(description = "当年收入(元)")
private BigDecimal yearIncome;
@Schema(description = "当年退款(元)")
private BigDecimal yearRefund;
@Schema(description = "当年订单数")
private Long yearOrderCount;
}
@@ -1,74 +0,0 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.ApiResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
/**
* 支付营业数据 Handler
*
* @author system
* @date 2026-06-29
*/
@Slf4j
@Component
@Tag(name = "支付营业数据", description = "营业额统计与支付记录查询")
public class PaymentRevenueHandler {
private final PaymentService paymentService;
public PaymentRevenueHandler(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "获取营业额统计", description = "获取今日、当月、当年的收入和退款情况")
public Mono<ServerResponse> getRevenueStatistics(ServerRequest request) {
log.info("[Revenue] 查询营业额统计");
return paymentService.getRevenueStatistics()
.flatMap(stats -> ServerResponse.ok()
.bodyValue(ApiResponse.success(stats)))
.onErrorResume(e -> {
log.error("[Revenue] 查询营业额统计失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询营业额统计失败: " + e.getMessage()));
});
}
@Operation(summary = "查询支付记录", description = "分页查询用户支付记录,支持按会员ID、支付状态、支付方式筛选")
public Mono<ServerResponse> getPaymentRecords(ServerRequest request) {
String memberIdStr = request.queryParam("memberId").orElse(null);
Long memberId = null;
if (memberIdStr != null && !memberIdStr.isEmpty()) {
try {
memberId = Long.parseLong(memberIdStr);
} catch (NumberFormatException e) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error("会员ID格式不正确"));
}
}
String payStatus = request.queryParam("payStatus").orElse(null);
String tradeType = request.queryParam("tradeType").orElse(null);
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
int pageSize = Integer.parseInt(request.queryParam("pageSize").orElse("20"));
log.info("[Revenue] 查询支付记录: memberId={}, payStatus={}, tradeType={}, page={}, pageSize={}",
memberId, payStatus, tradeType, page, pageSize);
return paymentService.getPaymentRecords(memberId, payStatus, tradeType, page, pageSize)
.flatMap(result -> ServerResponse.ok()
.bodyValue(ApiResponse.success(result)))
.onErrorResume(e -> {
log.error("[Revenue] 查询支付记录失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询支付记录失败: " + e.getMessage()));
});
}
}
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Repository @Repository
@@ -39,24 +38,4 @@ public interface PaymentOrderRepository extends R2dbcRepository<PaymentOrder, Lo
Flux<PaymentOrder> findAllByDeletedAtIsNull(); Flux<PaymentOrder> findAllByDeletedAtIsNull();
Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId); Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId);
// ===== 营业额统计 =====
/** 统计指定时间范围内成功支付的金额总和 */
@Query("SELECT COALESCE(SUM(trans_amt), 0) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<BigDecimal> sumSuccessAmount(LocalDateTime startTime, LocalDateTime endTime);
/** 统计指定时间范围内的成功订单数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<Long> countSuccessOrders(LocalDateTime startTime, LocalDateTime endTime);
// ===== 支付记录分页查询 =====
/** 按条件分页查询支付记录 */
@Query("SELECT * FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
Flux<PaymentOrder> findPaymentRecords(Long memberId, String payStatus, String tradeType, int limit, int offset);
/** 按条件统计支付记录总数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL")
Mono<Long> countPaymentRecords(Long memberId, String payStatus, String tradeType);
} }
@@ -1,9 +1,7 @@
package cn.novalon.gym.manage.payment.service; package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -32,10 +30,4 @@ public interface PaymentService {
Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType); Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType);
Mono<Boolean> closeOrder(Long memberId, String orderId); Mono<Boolean> closeOrder(Long memberId, String orderId);
/** 获取营业额统计(今日/当月/当年) */
Mono<RevenueStatistics> getRevenueStatistics();
/** 分页查询支付记录 */
Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize);
} }
@@ -3,9 +3,7 @@ package cn.novalon.gym.manage.payment.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.payment.config.HuifuProperties; import cn.novalon.gym.manage.payment.config.HuifuProperties;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import cn.novalon.gym.manage.payment.entity.PaymentOrder; import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository; import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository;
import cn.novalon.gym.manage.payment.service.PaymentNotifyService; import cn.novalon.gym.manage.payment.service.PaymentNotifyService;
@@ -936,76 +934,4 @@ public class PaymentServiceImpl implements PaymentService {
}) })
.switchIfEmpty(Mono.just(false)); .switchIfEmpty(Mono.just(false));
} }
@Override
public Mono<RevenueStatistics> getRevenueStatistics() {
LocalDateTime now = LocalDateTime.now();
// 今日
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 当月
LocalDateTime monthStart = now.toLocalDate().withDayOfMonth(1).atStartOfDay();
LocalDateTime monthEnd = monthStart.plusMonths(1);
// 当年
LocalDateTime yearStart = now.toLocalDate().withDayOfYear(1).atStartOfDay();
LocalDateTime yearEnd = yearStart.plusYears(1);
Mono<BigDecimal> todayIncomeMono = paymentOrderRepository.sumSuccessAmount(todayStart, todayEnd);
Mono<Long> todayCountMono = paymentOrderRepository.countSuccessOrders(todayStart, todayEnd);
Mono<BigDecimal> monthIncomeMono = paymentOrderRepository.sumSuccessAmount(monthStart, monthEnd);
Mono<Long> monthCountMono = paymentOrderRepository.countSuccessOrders(monthStart, monthEnd);
Mono<BigDecimal> yearIncomeMono = paymentOrderRepository.sumSuccessAmount(yearStart, yearEnd);
Mono<Long> yearCountMono = paymentOrderRepository.countSuccessOrders(yearStart, yearEnd);
return Mono.zip(todayIncomeMono, todayCountMono, monthIncomeMono, monthCountMono, yearIncomeMono, yearCountMono)
.map(tuple -> RevenueStatistics.builder()
.todayIncome(tuple.getT1() != null ? tuple.getT1() : BigDecimal.ZERO)
.todayRefund(BigDecimal.ZERO) // 退款功能暂未实现
.todayOrderCount(tuple.getT2() != null ? tuple.getT2() : 0L)
.monthIncome(tuple.getT3() != null ? tuple.getT3() : BigDecimal.ZERO)
.monthRefund(BigDecimal.ZERO) // 退款功能暂未实现
.monthOrderCount(tuple.getT4() != null ? tuple.getT4() : 0L)
.yearIncome(tuple.getT5() != null ? tuple.getT5() : BigDecimal.ZERO)
.yearRefund(BigDecimal.ZERO) // 退款功能暂未实现
.yearOrderCount(tuple.getT6() != null ? tuple.getT6() : 0L)
.build());
}
@Override
public Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize) {
int offset = (page - 1) * pageSize;
Mono<List<PaymentRecordResponse>> recordsMono = paymentOrderRepository
.findPaymentRecords(memberId, payStatus, tradeType, pageSize, offset)
.map(order -> PaymentRecordResponse.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.memberId(order.getMemberId())
.tradeType(order.getTradeType())
.goodsDesc(order.getGoodsDesc())
.orderType(order.getOrderType())
.transAmt(order.getTransAmt())
.payStatus(order.getPayStatus())
.payTime(order.getPayTime() != null
? order.getPayTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.hfSeqId(order.getHfSeqId())
.createdAt(order.getCreatedAt() != null
? order.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.build())
.collectList();
Mono<Long> totalMono = paymentOrderRepository.countPaymentRecords(memberId, payStatus, tradeType);
return Mono.zip(recordsMono, totalMono)
.map(tuple -> {
Map<String, Object> result = new HashMap<>();
result.put("records", tuple.getT1());
result.put("total", tuple.getT2());
result.put("page", page);
result.put("pageSize", pageSize);
return result;
});
}
} }
+2915 -1991
View File
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,9 @@ import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler; import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.BannerHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler; import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.groupcourse.handler.CommonUploadHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
@@ -21,7 +19,6 @@ import cn.novalon.gym.manage.member.handler.WechatAuthHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.payment.handler.PaymentRevenueHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -82,13 +79,10 @@ public class SystemRouter {
GroupCourseRecommendHandler groupCourseRecommendHandler, GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler, GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler, CourseLabelHandler courseLabelHandler,
BannerHandler bannerHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler, DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler, PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler, PaymentHandler paymentHandler) {
PaymentRevenueHandler paymentRevenueHandler,
CommonUploadHandler commonUploadHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由 ==========
@@ -176,7 +170,6 @@ public class SystemRouter {
.POST("/api/auth/login", authHandler::login) .POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register) .POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout) .POST("/api/auth/logout", authHandler::logout)
.GET("/api/auth/me", authHandler::me)
// ========== 统计路由 ========== // ========== 统计路由 ==========
.GET("/api/stats/overview", statsHandler::getOverview) .GET("/api/stats/overview", statsHandler::getOverview)
@@ -224,10 +217,6 @@ public class SystemRouter {
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile) .DELETE("/api/files/{id}", fileHandler::deleteFile)
// ===== 通用文件上传OSS=====
.POST("/api/upload/image", commonUploadHandler::uploadImage)
.GET("/api/upload/presign", commonUploadHandler::presignUrl)
// ========== 权限路由 ========== // ========== 权限路由 ==========
.GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById) .GET("/api/permissions/{id}", permissionHandler::getPermissionById)
@@ -319,6 +308,7 @@ public class SystemRouter {
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory) .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById) .GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType) .POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
.POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage)
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType) .PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType) .DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
@@ -327,6 +317,7 @@ public class SystemRouter {
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels) .GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById) .GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId) .GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
.POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage)
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel) .POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel) .PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel) .DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
@@ -352,26 +343,13 @@ public class SystemRouter {
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation) .POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation) .POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ========== 轮播图路由 ==========
.GET("/api/banner/list", bannerHandler::getAllBanners)
.GET("/api/banner/active", bannerHandler::getAllActiveBanners)
.GET("/api/banner/{id}", bannerHandler::getBannerById)
.POST("/api/banner", bannerHandler::createBanner)
.PUT("/api/banner/{id}", bannerHandler::updateBanner)
.DELETE("/api/banner/{id}", bannerHandler::deleteBanner)
.POST("/api/banner/{id}/enable", bannerHandler::enableBanner)
.POST("/api/banner/{id}/disable", bannerHandler::disableBanner)
// ===== 团课课程管理需要放在具体路由之后===== // ===== 团课课程管理需要放在具体路由之后=====
.GET("/api/groupCourse/{id}/qrcode", groupCourseHandler::getCourseQRCode)
.POST("/api/groupCourse/{courseId}/qrsignin", groupCourseBookingHandler::qrSignIn)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById) .GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById) .GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) .POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse) .PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse) .DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{id}/restore", groupCourseHandler::restoreGroupCourse)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn) .POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses) .POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
@@ -381,17 +359,15 @@ public class SystemRouter {
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode) .GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 ===== // ===== 签到记录管理 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/records", checkInHandler::getSignInRecords) .GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById) .GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
// ===== 签到统计 ===== // ===== 签到统计 =====
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics) .GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats) .GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 管理员签到记录全员 ===== // ===== 签到数据导出 =====
.GET("/api/checkIn/admin/records", checkInHandler::getAllSignInRecords) .GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/admin/statistics", checkInHandler::getAllSignInStatistics)
// ======================================== // ========================================
// ========== 数据统计模块路由 ============ // ========== 数据统计模块路由 ============
@@ -420,10 +396,6 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment) .POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder) .POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ===== 支付营业数据 =====
.GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics)
.GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords)
.build(); .build();
} }
} }
-4
View File
@@ -60,10 +60,6 @@
<groupId>org.springframework.data</groupId> <groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId> <artifactId>spring-data-redis</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -3,6 +3,7 @@ package cn.novalon.gym.manage.common.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
@@ -11,6 +12,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali
import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/** /**
* Redis 配置类响应式版本 * Redis 配置类响应式版本
* *
@@ -20,30 +24,41 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration @Configuration
public class RedisConfig { public class RedisConfig {
/**
* 创建支持 Java 8 时间类型与类型信息的 ObjectMapper用于 Redis JSON 序列化
*/
private ObjectMapper createRedisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
mapper.registerModule(javaTimeModule);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 启用默认类型信息确保反序列化时能还原为原始 Java 类型
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
return mapper;
}
/** /**
* 配置 ReactiveRedisTemplate * 配置 ReactiveRedisTemplate
*/ */
@Bean @Bean
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate( public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
ReactiveRedisConnectionFactory connectionFactory) { ReactiveRedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(createRedisObjectMapper());
// 配置序列化上下文 // 配置序列化上下文
RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext<String, Object> serializationContext =
RedisSerializationContext.<String, Object>newSerializationContext() RedisSerializationContext.<String, Object>newSerializationContext()
.key(StringRedisSerializer.UTF_8) .key(StringRedisSerializer.UTF_8)
.value(serializer) .value(jsonSerializer)
.hashKey(StringRedisSerializer.UTF_8) .hashKey(StringRedisSerializer.UTF_8)
.hashValue(serializer) .hashValue(jsonSerializer)
.build(); .build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
@@ -12,6 +12,8 @@ public class PageRequest {
private String sort = "id"; private String sort = "id";
private String order = "asc"; private String order = "asc";
private String keyword; private String keyword;
private String status;
private String category;
public int getPage() { public int getPage() {
return page; return page;
@@ -52,4 +54,20 @@ public class PageRequest {
public void setKeyword(String keyword) { public void setKeyword(String keyword) {
this.keyword = keyword; this.keyword = keyword;
} }
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
} }
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.common.util; package cn.novalon.gym.manage.common.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -13,6 +14,7 @@ import java.time.Duration;
* @author liwentao * @author liwentao
* @date 2026/5/15 * @date 2026/5/15
*/ */
@Slf4j
@Component @Component
public class RedisUtil { public class RedisUtil {
@@ -34,12 +36,19 @@ public class RedisUtil {
} }
/** /**
* 获取值 * 获取值带类型转换
* filter+cast 替代 map 避免 NPEonErrorResume 兼容旧缓存格式反序列化失败
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> Mono<T> get(String key, Class<T> clazz) { public <T> Mono<T> get(String key, Class<T> clazz) {
return reactiveRedisTemplate.opsForValue().get(key) return reactiveRedisTemplate.opsForValue().get(key)
.map(obj -> clazz.isInstance(obj) ? (T) obj : null); .filter(clazz::isInstance)
.cast(clazz)
.onErrorResume(e -> {
// 旧缓存数据格式不兼容时静默跳过后续逻辑会 fallback DB 查询
log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage());
return Mono.empty();
});
} }
/** /**
@@ -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));
@@ -1,7 +0,0 @@
-- ============================================
-- 团课新增常态化标记
-- ============================================
ALTER TABLE group_course ADD COLUMN IF NOT EXISTS is_recurring BOOLEAN DEFAULT FALSE;
COMMENT ON COLUMN group_course.is_recurring IS '是否常态化团课:TRUE-是,FALSE-否(默认)';
@@ -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));
@@ -1,36 +0,0 @@
-- ============================================
-- 轮播图表
-- ============================================
CREATE TABLE IF NOT EXISTS banner (
id BIGSERIAL PRIMARY KEY,
image_url TEXT NOT NULL,
title VARCHAR(100) NOT NULL,
subtitle VARCHAR(100),
description TEXT,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_banner_sort_order ON banner(sort_order);
CREATE INDEX IF NOT EXISTS idx_banner_is_active ON banner(is_active);
CREATE INDEX IF NOT EXISTS idx_banner_deleted_at ON banner(deleted_at);
COMMENT ON TABLE banner IS '轮播图表';
COMMENT ON COLUMN banner.id IS '主键ID';
COMMENT ON COLUMN banner.image_url IS '背景图URL';
COMMENT ON COLUMN banner.title IS '主标题';
COMMENT ON COLUMN banner.subtitle IS '副标题';
COMMENT ON COLUMN banner.description IS '简介';
COMMENT ON COLUMN banner.sort_order IS '排序(数值越大越靠前)';
COMMENT ON COLUMN banner.is_active IS '是否启用';
COMMENT ON COLUMN banner.create_by IS '创建人';
COMMENT ON COLUMN banner.update_by IS '更新人';
COMMENT ON COLUMN banner.created_at IS '创建时间';
COMMENT ON COLUMN banner.updated_at IS '更新时间';
COMMENT ON COLUMN banner.deleted_at IS '删除时间(软删除)';
@@ -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));
@@ -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));
@@ -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));
+6
View File
@@ -106,6 +106,12 @@
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
</dependency> </dependency>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -9,17 +9,24 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.LinkedHashMap; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component @Component
@Order(Ordered.LOWEST_PRECEDENCE) @Order(Ordered.LOWEST_PRECEDENCE)
@@ -30,76 +37,19 @@ public class OperationLogWebFilter implements WebFilter {
private final IOperationLogService operationLogService; private final IOperationLogService operationLogService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** 精确匹配的操作映射(method:path → module, operation */ private static final Map<String, OperationInfo> OPERATION_MAPPING = new ConcurrentHashMap<>();
private static final Map<String, OperationInfo> PRECISE_MAPPING = new LinkedHashMap<>();
/** 前缀匹配的操作映射(按声明顺序) */
private static final Map<String, OperationInfo> PREFIX_MAPPING = new LinkedHashMap<>();
/** URL模块名称映射 */
private static final Map<String, String> MODULE_NAMES = new LinkedHashMap<>();
static { static {
// ===== 精确路径匹配 ===== OPERATION_MAPPING.put("POST:/api/roles", new OperationInfo("角色管理", "创建角色"));
PRECISE_MAPPING.put("POST:/api/roles", new OperationInfo("角色管理", "创建角色")); OPERATION_MAPPING.put("PUT:/api/roles/", new OperationInfo("角色管理", "更新角色"));
PRECISE_MAPPING.put("POST:/api/users", new OperationInfo("用户管理", "创建用户")); OPERATION_MAPPING.put("DELETE:/api/roles/", new OperationInfo("角色管理", "删除角色"));
PRECISE_MAPPING.put("POST:/api/menus", new OperationInfo("菜单管理", "创建菜单")); OPERATION_MAPPING.put("POST:/api/users", new OperationInfo("用户管理", "创建用户"));
PRECISE_MAPPING.put("POST:/api/auth/login", new OperationInfo("认证", "用户登录")); OPERATION_MAPPING.put("PUT:/api/users/", new OperationInfo("用户管理", "更新用户"));
PRECISE_MAPPING.put("GET:/api/groupCourse/types/categories", new OperationInfo("团课类型", "查询分类")); OPERATION_MAPPING.put("DELETE:/api/users/", new OperationInfo("用户管理", "删除用户"));
PRECISE_MAPPING.put("POST:/api/groupCourse/types", new OperationInfo("团课类型", "创建类型")); OPERATION_MAPPING.put("POST:/api/users/", new OperationInfo("用户管理", "用户操作"));
PRECISE_MAPPING.put("POST:/api/groupCourse", new OperationInfo("团课管理", "创建团课")); OPERATION_MAPPING.put("POST:/api/menus", new OperationInfo("菜单管理", "创建菜单"));
PRECISE_MAPPING.put("POST:/api/member", new OperationInfo("会员管理", "创建会员")); OPERATION_MAPPING.put("PUT:/api/menus/", new OperationInfo("菜单管理", "更新菜单"));
PRECISE_MAPPING.put("POST:/api/member-cards", new OperationInfo("会员卡管理", "创建会员卡")); OPERATION_MAPPING.put("DELETE:/api/menus/", new OperationInfo("菜单管理", "删除菜单"));
PRECISE_MAPPING.put("POST:/api/groupCourse/recommend", new OperationInfo("推荐管理", "创建推荐"));
PRECISE_MAPPING.put("POST:/api/checkIn", new OperationInfo("签到管理", "签到"));
PRECISE_MAPPING.put("POST:/api/payment/create", new OperationInfo("支付管理", "创建支付"));
PRECISE_MAPPING.put("POST:/api/upload/image", new OperationInfo("文件管理", "上传图片"));
// ===== 前缀匹配 =====
PREFIX_MAPPING.put("PUT:/api/roles/", new OperationInfo("角色管理", "更新角色"));
PREFIX_MAPPING.put("DELETE:/api/roles/", new OperationInfo("角色管理", "删除角色"));
PREFIX_MAPPING.put("PUT:/api/users/", new OperationInfo("用户管理", "更新用户"));
PREFIX_MAPPING.put("DELETE:/api/users/", new OperationInfo("用户管理", "删除用户"));
PREFIX_MAPPING.put("PUT:/api/menus/", new OperationInfo("菜单管理", "更新菜单"));
PREFIX_MAPPING.put("DELETE:/api/menus/", new OperationInfo("菜单管理", "删除菜单"));
PREFIX_MAPPING.put("PUT:/api/groupCourse/types/", new OperationInfo("团课类型", "更新类型"));
PREFIX_MAPPING.put("DELETE:/api/groupCourse/types/", new OperationInfo("团课类型", "删除类型"));
PREFIX_MAPPING.put("PUT:/api/groupCourse/", new OperationInfo("团课管理", "更新团课"));
PREFIX_MAPPING.put("DELETE:/api/groupCourse/", new OperationInfo("团课管理", "删除团课"));
PREFIX_MAPPING.put("POST:/api/groupCourse/", new OperationInfo("团课管理", "操作团课"));
PREFIX_MAPPING.put("PUT:/api/member/", new OperationInfo("会员管理", "编辑会员"));
PREFIX_MAPPING.put("DELETE:/api/member/", new OperationInfo("会员管理", "删除会员"));
PREFIX_MAPPING.put("PUT:/api/member-cards/", new OperationInfo("会员卡管理", "编辑会员卡"));
PREFIX_MAPPING.put("DELETE:/api/member-cards/", new OperationInfo("会员卡管理", "删除会员卡"));
PREFIX_MAPPING.put("PUT:/api/groupCourse/recommend/", new OperationInfo("推荐管理", "更新推荐"));
PREFIX_MAPPING.put("DELETE:/api/groupCourse/recommend/", new OperationInfo("推荐管理", "删除推荐"));
PREFIX_MAPPING.put("POST:/api/groupCourse/recommend/", new OperationInfo("推荐管理", "操作推荐"));
PREFIX_MAPPING.put("POST:/api/member-card-transactions/", new OperationInfo("会员卡", "交易操作"));
PREFIX_MAPPING.put("PUT:/api/payment/", new OperationInfo("支付管理", "更新支付"));
PREFIX_MAPPING.put("POST:/api/payment/", new OperationInfo("支付管理", "支付操作"));
PREFIX_MAPPING.put("PUT:/api/admin/member/", new OperationInfo("会员管理", "管理员编辑会员"));
PREFIX_MAPPING.put("DELETE:/api/admin/member/", new OperationInfo("会员管理", "管理员删除会员"));
PREFIX_MAPPING.put("DELETE:/api/groupCourse/labels/", new OperationInfo("标签管理", "删除标签"));
// ===== URL模块名映射用于未匹配写操作自动生成 =====
MODULE_NAMES.put("roles", "角色管理");
MODULE_NAMES.put("users", "用户管理");
MODULE_NAMES.put("menus", "菜单管理");
MODULE_NAMES.put("auth", "认证");
MODULE_NAMES.put("groupCourse", "团课管理");
MODULE_NAMES.put("member", "会员管理");
MODULE_NAMES.put("member-cards", "会员卡管理");
MODULE_NAMES.put("member-card-records", "会员卡记录");
MODULE_NAMES.put("member-card-transactions", "会员卡交易");
MODULE_NAMES.put("checkIn", "签到管理");
MODULE_NAMES.put("payment", "支付管理");
MODULE_NAMES.put("dictionaries", "字典管理");
MODULE_NAMES.put("config", "系统配置");
MODULE_NAMES.put("upload", "文件管理");
MODULE_NAMES.put("logs", "日志管理");
MODULE_NAMES.put("datacount", "数据统计");
MODULE_NAMES.put("diagnostic", "诊断");
MODULE_NAMES.put("stats", "统计");
} }
public OperationLogWebFilter(IOperationLogService operationLogService, ObjectMapper objectMapper) { public OperationLogWebFilter(IOperationLogService operationLogService, ObjectMapper objectMapper) {
@@ -111,8 +61,10 @@ public class OperationLogWebFilter implements WebFilter {
@PostConstruct @PostConstruct
public void init() { public void init() {
logger.info("=== OperationLogWebFilter 初始化 ==="); logger.info("=== OperationLogWebFilter 初始化 ===");
logger.info("精确匹配配置数量: {}, 前缀匹配配置数量: {}, 模块映射数量: {}", logger.info("操作日志映射配置数量: {}", OPERATION_MAPPING.size());
PRECISE_MAPPING.size(), PREFIX_MAPPING.size(), MODULE_NAMES.size()); OPERATION_MAPPING.forEach((key, value) -> {
logger.info(" {} -> {}:{}", key, value.module, value.operation);
});
} }
@Override @Override
@@ -120,135 +72,103 @@ public class OperationLogWebFilter implements WebFilter {
ServerHttpRequest request = exchange.getRequest(); ServerHttpRequest request = exchange.getRequest();
String method = request.getMethod().name(); String method = request.getMethod().name();
String path = request.getPath().value(); String path = request.getPath().value();
String key = method + ":" + path;
// 先尝试精确匹配 logger.info("WebFilter 拦截请求: {} {}", method, path);
OperationInfo operationInfo = PRECISE_MAPPING.get(key);
if (operationInfo == null) { OperationInfo operationInfo = findOperationInfo(method, path);
// 尝试前缀匹配
operationInfo = findPrefixMatch(key);
}
if (operationInfo == null) {
// 未匹配如果是写操作(POST/PUT/DELETE)自动生成记录
if (isWriteOperation(method)) {
operationInfo = buildAutoOperationInfo(method, path);
logger.info("自动生成操作日志: {} {} -> {}:{}", method, path, operationInfo.module, operationInfo.operation);
}
} else {
logger.info("匹配到操作日志配置: {} {} -> {}:{}", method, path, operationInfo.module, operationInfo.operation);
}
if (operationInfo == null) { if (operationInfo == null) {
logger.info("未匹配到操作日志配置,跳过: {} {}", method, path);
return chain.filter(exchange); return chain.filter(exchange);
} }
logger.info("匹配到操作日志配置: {} {} -> {}:{}", method, path, operationInfo.module, operationInfo.operation);
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String ip = IpUtils.getClientIp(request); String ip = IpUtils.getClientIp(request);
final OperationInfo finalInfo = operationInfo;
return chain.filter(exchange) return Mono.deferContextual(contextView -> {
.then(Mono.defer(() -> { return chain.filter(exchange)
long duration = System.currentTimeMillis() - startTime; .then(Mono.defer(() -> {
return getCurrentUsername() long duration = System.currentTimeMillis() - startTime;
.flatMap(username -> saveOperationLog(username, method, path, ip, duration, "0", null, finalInfo)); logger.info("请求处理完成,准备保存操作日志: {} {}, 耗时: {}ms", method, path, duration);
}))
.onErrorResume(error -> { return ReactiveSecurityContextHolder.getContext()
long duration = System.currentTimeMillis() - startTime; .flatMap(securityContext -> {
logger.error("请求处理失败: {} {}, 错误: {}", method, path, error.getMessage()); Object principal = securityContext.getAuthentication().getPrincipal();
return getCurrentUsername() String username = principal instanceof String ? (String) principal : "system";
.flatMap(username -> saveOperationLog(username, method, path, ip, duration, "1", logger.info("获取到用户名: {}", username);
error.getMessage().substring(0, Math.min(error.getMessage().length(), 500)), finalInfo)) return Mono.just(username);
.then(Mono.error(error)); })
}); .defaultIfEmpty("system")
.flatMap(username -> {
logger.info("开始保存操作日志: 用户={}, 操作={}", username,
operationInfo.module + " - " + operationInfo.operation);
OperationLog log = new OperationLog();
log.setUsername(username);
log.setOperation(operationInfo.module + " - " + operationInfo.operation);
log.setMethod(method + " " + path);
log.setParams(null);
log.setIp(ip);
log.setDuration(duration);
log.setStatus("0");
return operationLogService.save(log)
.doOnSuccess(saved -> logger.info("操作日志保存成功: {} - {}",
operationInfo.module, operationInfo.operation))
.doOnError(e -> logger.error("操作日志保存失败: {}", e.getMessage(), e))
.onErrorResume(e -> Mono.empty());
})
.then();
}))
.onErrorResume(error -> {
long duration = System.currentTimeMillis() - startTime;
logger.error("请求处理失败: {} {}, 错误: {}", method, path, error.getMessage());
return ReactiveSecurityContextHolder.getContext()
.flatMap(securityContext -> {
Object principal = securityContext.getAuthentication().getPrincipal();
String username = principal instanceof String ? (String) principal : "system";
return Mono.just(username);
})
.defaultIfEmpty("system")
.flatMap(username -> {
OperationLog log = new OperationLog();
log.setUsername(username);
log.setOperation(operationInfo.module + " - " + operationInfo.operation);
log.setMethod(method + " " + path);
log.setParams(null);
log.setIp(ip);
log.setDuration(duration);
log.setStatus("1");
log.setErrorMsg(error.getMessage());
return operationLogService.save(log)
.doOnError(e -> logger.error("错误日志保存失败: {}", e.getMessage()))
.onErrorResume(e -> Mono.empty());
})
.then(Mono.error(error));
});
});
} }
private boolean isWriteOperation(String method) { private OperationInfo findOperationInfo(String method, String path) {
return HttpMethod.POST.name().equals(method) || String key = method + ":" + path;
HttpMethod.PUT.name().equals(method) || if (OPERATION_MAPPING.containsKey(key)) {
HttpMethod.DELETE.name().equals(method) || return OPERATION_MAPPING.get(key);
HttpMethod.PATCH.name().equals(method); }
}
private OperationInfo findPrefixMatch(String key) { for (Map.Entry<String, OperationInfo> entry : OPERATION_MAPPING.entrySet()) {
for (Map.Entry<String, OperationInfo> entry : PREFIX_MAPPING.entrySet()) { String mappingKey = entry.getKey();
if (key.startsWith(entry.getKey())) { if (key.startsWith(mappingKey)) {
return entry.getValue(); return entry.getValue();
} }
} }
return null; return null;
} }
/**
* 根据URL路径自动生成操作信息
* 例如: DELETE:/api/groupCourse/types/5 团课管理 - 删除操作
*/
private OperationInfo buildAutoOperationInfo(String method, String path) {
String module = extractModuleFromPath(path);
String operation = methodToOperationName(method);
return new OperationInfo(module, operation);
}
private String extractModuleFromPath(String path) {
// 去掉 /api/ 前缀取第一段作为模块名
if (path.startsWith("/api/")) {
String subPath = path.substring(5); // remove "/api/"
int slashIdx = subPath.indexOf('/');
String moduleKey = slashIdx > 0 ? subPath.substring(0, slashIdx) : subPath;
// 尝试复合模块名 ( member-cards)
if (slashIdx > 0) {
String rest = subPath.substring(slashIdx + 1);
int nextSlash = rest.indexOf('/');
String secondPart = nextSlash > 0 ? rest.substring(0, nextSlash) : rest;
String compositeKey = moduleKey + "/" + secondPart;
if (MODULE_NAMES.containsKey(compositeKey)) {
return MODULE_NAMES.get(compositeKey);
}
}
return MODULE_NAMES.getOrDefault(moduleKey, moduleKey);
}
return path;
}
private String methodToOperationName(String method) {
switch (method.toUpperCase()) {
case "POST": return "创建/新增操作";
case "PUT": return "编辑/更新操作";
case "DELETE": return "删除操作";
case "PATCH": return "修改操作";
default: return "操作";
}
}
private Mono<String> getCurrentUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getPrincipal())
.map(principal -> principal instanceof String ? (String) principal : "system")
.defaultIfEmpty("system")
.onErrorReturn("system");
}
private Mono<Void> saveOperationLog(String username, String method, String path, String ip,
long duration, String status, String errorMsg, OperationInfo info) {
OperationLog log = new OperationLog();
log.setUsername(username);
log.setOperation(info.module + " - " + info.operation);
log.setMethod(method + " " + path);
log.setIp(ip);
log.setDuration(duration);
log.setStatus(status);
log.setErrorMsg(errorMsg);
return operationLogService.save(log)
.doOnSuccess(saved -> logger.debug("操作日志保存成功: {} - {}", info.module, info.operation))
.doOnError(e -> logger.error("操作日志保存失败: {}", e.getMessage(), e))
.onErrorResume(e -> Mono.empty())
.then();
}
private static class OperationInfo { private static class OperationInfo {
final String module; final String module;
final String operation; final String operation;
@@ -11,11 +11,6 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux
import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration @Configuration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@@ -34,20 +29,6 @@ public class SecurityConfig {
this.environment = environment; this.environment = environment;
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean @Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
String[] activeProfiles = environment.getActiveProfiles(); String[] activeProfiles = environment.getActiveProfiles();
@@ -60,7 +41,6 @@ public class SecurityConfig {
activeProfiles.length > 0 ? String.join(",", activeProfiles) : "default", isDevOrTest); activeProfiles.length > 0 ? String.join(",", activeProfiles) : "default", isDevOrTest);
http http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(ServerHttpSecurity.CsrfSpec::disable) .csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
@@ -53,8 +53,6 @@ public interface ISysUserService {
Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword); Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword);
Mono<Boolean> verifyPassword(Long userId, String password);
Mono<Void> updateRoleIdToNullByRoleId(Long roleId); Mono<Void> updateRoleIdToNullByRoleId(Long roleId);
Mono<Void> assignRolesToUser(Long userId, java.util.List<Long> roleIds); Mono<Void> assignRolesToUser(Long userId, java.util.List<Long> roleIds);
@@ -120,9 +120,6 @@ public class SysPermissionService implements ISysPermissionService {
@Override @Override
public Flux<SysPermission> findByRoleIds(List<Long> roleIds) { public Flux<SysPermission> findByRoleIds(List<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return Flux.empty();
}
return permissionRepository.findByRoleIds(roleIds); return permissionRepository.findByRoleIds(roleIds);
} }
@@ -83,6 +83,7 @@ public class SysRoleService implements ISysRoleService {
@Override @Override
public Mono<SysRole> createRole(CreateRoleCommand command) { public Mono<SysRole> createRole(CreateRoleCommand command) {
SysRole role = new SysRole(); SysRole role = new SysRole();
role.generateId();
role.setRoleName(command.roleName()); role.setRoleName(command.roleName());
role.setRoleKey(command.roleKey()); role.setRoleKey(command.roleKey());
role.setRoleSort(command.roleSort()); role.setRoleSort(command.roleSort());
@@ -97,6 +97,7 @@ public class SysUserService implements ISysUserService {
logger.info("SysUserService.createUser - 用户名: {}, 密码前缀: {}", logger.info("SysUserService.createUser - 用户名: {}, 密码前缀: {}",
user.getUsername(), user.getUsername(),
user.getPassword() != null ? user.getPassword().substring(0, 7) : "null"); user.getPassword() != null ? user.getPassword().substring(0, 7) : "null");
user.generateId();
if (user.getPassword() != null && !user.getPassword().startsWith("$2a$") if (user.getPassword() != null && !user.getPassword().startsWith("$2a$")
&& !user.getPassword().startsWith("$2b$")) { && !user.getPassword().startsWith("$2b$")) {
logger.info("密码不以$2a$或$2b$开头,重新编码"); logger.info("密码不以$2a$或$2b$开头,重新编码");
@@ -116,6 +117,7 @@ public class SysUserService implements ISysUserService {
@Override @Override
public Mono<SysUser> createUser(CreateUserCommand command) { public Mono<SysUser> createUser(CreateUserCommand command) {
SysUser user = new SysUser(); SysUser user = new SysUser();
user.generateId();
user.setUsername(command.username().getValue()); user.setUsername(command.username().getValue());
user.setPassword(passwordEncoder.encode(command.password().getValue())); user.setPassword(passwordEncoder.encode(command.password().getValue()));
user.setEmail(command.email().getValue()); user.setEmail(command.email().getValue());
@@ -202,13 +204,6 @@ public class SysUserService implements ISysUserService {
return userRepository.updateRoleIdToNullByRoleId(roleId); return userRepository.updateRoleIdToNullByRoleId(roleId);
} }
@Override
public Mono<Boolean> verifyPassword(Long userId, String password) {
return userRepository.findById(userId)
.map(user -> passwordEncoder.matches(password, user.getPassword()))
.defaultIfEmpty(false);
}
@Override @Override
public Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword) { public Mono<SysUser> changePassword(Long userId, String oldPassword, String newPassword) {
return userRepository.findById(userId) return userRepository.findById(userId)
@@ -2,8 +2,6 @@ package cn.novalon.gym.manage.sys.dto.response;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** /**
* 认证响应DTO * 认证响应DTO
* *
@@ -22,21 +20,13 @@ public class AuthResponse {
@Schema(description = "用户名", example = "admin") @Schema(description = "用户名", example = "admin")
private String username; private String username;
@Schema(description = "角色标识列表", example = "[\"admin\"]")
private List<String> roles;
@Schema(description = "权限码列表", example = "[\"system:user:view\", \"system:user:create\"]")
private List<String> permissions;
public AuthResponse() { public AuthResponse() {
} }
public AuthResponse(String token, Long userId, String username, List<String> roles, List<String> permissions) { public AuthResponse(String token, Long userId, String username) {
this.token = token; this.token = token;
this.userId = userId; this.userId = userId;
this.username = username; this.username = username;
this.roles = roles;
this.permissions = permissions;
} }
public String getToken() { public String getToken() {
@@ -62,20 +52,4 @@ public class AuthResponse {
public void setUsername(String username) { public void setUsername(String username) {
this.username = username; this.username = username;
} }
public List<String> getRoles() {
return roles;
}
public void setRoles(List<String> roles) {
this.roles = roles;
}
public List<String> getPermissions() {
return permissions;
}
public void setPermissions(List<String> permissions) {
this.permissions = permissions;
}
} }
@@ -8,7 +8,6 @@ import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.SysLoginLog; import cn.novalon.gym.manage.sys.core.domain.SysLoginLog;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.core.service.ISysLoginLogService; import cn.novalon.gym.manage.sys.core.service.ISysLoginLogService;
import cn.novalon.gym.manage.sys.core.service.ISysPermissionService;
import cn.novalon.gym.manage.sys.util.UserAgentParser; import cn.novalon.gym.manage.sys.util.UserAgentParser;
import cn.novalon.gym.manage.sys.util.IpLocationParser; import cn.novalon.gym.manage.sys.util.IpLocationParser;
import cn.novalon.gym.manage.common.util.StatusConstants; import cn.novalon.gym.manage.common.util.StatusConstants;
@@ -29,7 +28,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -52,7 +50,6 @@ public class SysAuthHandler {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final ISysLoginLogService loginLogService; private final ISysLoginLogService loginLogService;
private final ISysPermissionService permissionService;
private final UserAgentParser userAgentParser; private final UserAgentParser userAgentParser;
private final IpLocationParser ipLocationParser; private final IpLocationParser ipLocationParser;
@@ -63,13 +60,11 @@ public class SysAuthHandler {
public SysAuthHandler(ISysUserService userService, public SysAuthHandler(ISysUserService userService,
@Qualifier("passwordEncoder") PasswordEncoder passwordEncoder, @Qualifier("passwordEncoder") PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider, ISysLoginLogService loginLogService, JwtTokenProvider jwtTokenProvider, ISysLoginLogService loginLogService,
ISysPermissionService permissionService,
UserAgentParser userAgentParser, IpLocationParser ipLocationParser) { UserAgentParser userAgentParser, IpLocationParser ipLocationParser) {
this.userService = userService; this.userService = userService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.loginLogService = loginLogService; this.loginLogService = loginLogService;
this.permissionService = permissionService;
this.userAgentParser = userAgentParser; this.userAgentParser = userAgentParser;
this.ipLocationParser = ipLocationParser; this.ipLocationParser = ipLocationParser;
@@ -131,49 +126,29 @@ public class SysAuthHandler {
} }
return userService.getUserRoles(user.getId()) return userService.getUserRoles(user.getId())
.map(role -> role.getRoleKey())
.collectList() .collectList()
.flatMap(roles -> { .flatMap(roleKeys -> {
List<String> roleKeys = roles.stream() String token = jwtTokenProvider
.map(r -> r.getRoleKey()) .generateToken(
.collect(Collectors.toList());
List<Long> roleIds = roles.stream()
.map(r -> r.getId())
.collect(Collectors.toList());
Mono<List<String>> permCodesMono;
if (roleIds.isEmpty()) {
permCodesMono = Mono.just(java.util.Collections.<String>emptyList());
} else {
permCodesMono = permissionService.findByRoleIds(roleIds)
.map(p -> p.getPermissionCode())
.collectList();
}
return permCodesMono
.flatMap(permCodes -> {
String token = jwtTokenProvider
.generateToken(
user.getUsername(), user.getUsername(),
user.getId(), user.getId(),
roleKeys); roleKeys);
logger.info("用户登录成功: username={}, userId={}, roles={}, permissions={}", logger.info("用户登录成功: username={}, userId={}, roles={}",
user.getUsername(), user.getUsername(),
user.getId(), user.getId(),
roleKeys, roleKeys);
permCodes.size()); recordLoginLog(loginRequest
recordLoginLog(loginRequest.getUsername(), .getUsername(),
clientIp, clientIp,
"0", "登录成功", "0", "登录成功",
userAgent); userAgent);
AuthResponse response = new AuthResponse( AuthResponse response = new AuthResponse(
token, token,
user.getId(), user.getId(),
user.getUsername(), user.getUsername());
roleKeys, return ServerResponse.ok()
permCodes); .bodyValue(response);
return ServerResponse.ok()
.bodyValue(response);
});
}); });
}) })
.switchIfEmpty(Mono.defer(() -> { .switchIfEmpty(Mono.defer(() -> {
@@ -215,50 +190,6 @@ public class SysAuthHandler {
}); });
} }
@Operation(summary = "获取当前用户信息", description = "根据Token获取当前登录用户的详细信息和权限")
public Mono<ServerResponse> me(ServerRequest request) {
String authHeader = request.headers().firstHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED).build();
}
String token = authHeader.substring(7);
if (!jwtTokenProvider.validateToken(token)) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED).build();
}
Long userId = jwtTokenProvider.getUserIdFromToken(token);
return userService.findById(userId)
.flatMap(user -> userService.getUserRoles(user.getId())
.collectList()
.flatMap(roles -> {
List<String> roleKeys = roles.stream()
.map(r -> r.getRoleKey())
.collect(Collectors.toList());
List<Long> roleIds = roles.stream()
.map(r -> r.getId())
.collect(Collectors.toList());
Mono<List<String>> permCodesMono;
if (roleIds.isEmpty()) {
permCodesMono = Mono.just(java.util.Collections.<String>emptyList());
} else {
permCodesMono = permissionService.findByRoleIds(roleIds)
.map(p -> p.getPermissionCode())
.collectList();
}
return permCodesMono
.flatMap(permCodes -> {
AuthResponse response = new AuthResponse(
token,
user.getId(),
user.getUsername(),
roleKeys,
permCodes);
return ServerResponse.ok().bodyValue(response);
});
}));
}
private void recordLoginLog(String username, String ip, String status, String message, String userAgent) { private void recordLoginLog(String username, String ip, String status, String message, String userAgent) {
try { try {
SysLoginLog loginLog = new SysLoginLog(); SysLoginLog loginLog = new SysLoginLog();
@@ -1,11 +1,7 @@
package cn.novalon.gym.manage.sys.handler.permission; package cn.novalon.gym.manage.sys.handler.permission;
import cn.novalon.gym.manage.sys.core.domain.SysPermission; import cn.novalon.gym.manage.sys.core.domain.SysPermission;
import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.service.ISysPermissionService; import cn.novalon.gym.manage.sys.core.service.ISysPermissionService;
import cn.novalon.gym.manage.sys.core.service.ISysRoleService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -26,19 +22,10 @@ import java.util.List;
@Tag(name = "权限管理", description = "权限相关操作") @Tag(name = "权限管理", description = "权限相关操作")
public class SysPermissionHandler { public class SysPermissionHandler {
private static final Long BUILTIN_ROLE_ID = 1L;
private final ISysPermissionService permissionService; private final ISysPermissionService permissionService;
private final ISysRoleService roleService;
private final ISysUserService userService;
private final AuthUtil authUtil;
public SysPermissionHandler(ISysPermissionService permissionService, ISysRoleService roleService, public SysPermissionHandler(ISysPermissionService permissionService) {
ISysUserService userService, AuthUtil authUtil) {
this.permissionService = permissionService; this.permissionService = permissionService;
this.roleService = roleService;
this.userService = userService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有权限", description = "获取系统中所有权限列表") @Operation(summary = "获取所有权限", description = "获取系统中所有权限列表")
@@ -110,27 +97,12 @@ public class SysPermissionHandler {
.body(permissionService.getPermissionsByRoleId(roleId), SysPermission.class); .body(permissionService.getPermissionsByRoleId(roleId), SysPermission.class);
} }
@Operation(summary = "为角色分配权限", description = "为指定角色分配权限列表,需验证管理员密码,超级管理员角色不可被分配") @Operation(summary = "为角色分配权限", description = "为指定角色分配权限列表")
public Mono<ServerResponse> assignPermissionsToRole(ServerRequest request) { public Mono<ServerResponse> assignPermissionsToRole(ServerRequest request) {
Long roleId = Long.valueOf(request.pathVariable("id")); Long roleId = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(AssignPermissionsRequest.class)
return verifyAdminPassword(request) .flatMap(req -> permissionService.assignPermissionsToRole(roleId, req.permissionIds()))
.flatMap(valid -> { .then(ServerResponse.ok().build());
if (!valid) return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
if (BUILTIN_ROLE_ID.equals(roleId)) return ServerResponse.badRequest().bodyValue("超级管理员角色权限不可被修改");
return request.bodyToMono(AssignPermissionsRequest.class)
.flatMap(req -> permissionService.assignPermissionsToRole(roleId, req.permissionIds()))
.then(ServerResponse.ok().build());
});
}
private Mono<Boolean> verifyAdminPassword(ServerRequest request) {
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return Mono.just(false);
}
Long adminId = authUtil.getMemberIdOrThrow(request);
return userService.verifyPassword(adminId, adminPassword);
} }
private record AssignPermissionsRequest(List<Long> permissionIds) {} private record AssignPermissionsRequest(List<Long> permissionIds) {}
@@ -2,8 +2,6 @@ package cn.novalon.gym.manage.sys.handler.role;
import cn.novalon.gym.manage.sys.core.domain.SysRole; import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.service.ISysRoleService; import cn.novalon.gym.manage.sys.core.service.ISysRoleService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.sys.dto.request.RoleCreateRequest; import cn.novalon.gym.manage.sys.dto.request.RoleCreateRequest;
import cn.novalon.gym.manage.sys.dto.request.RoleUpdateRequest; import cn.novalon.gym.manage.sys.dto.request.RoleUpdateRequest;
@@ -32,18 +30,12 @@ import java.util.Map;
@Tag(name = "角色管理", description = "角色相关操作") @Tag(name = "角色管理", description = "角色相关操作")
public class SysRoleHandler { public class SysRoleHandler {
private static final Long BUILTIN_ROLE_ID = 1L;
private final ISysRoleService roleService; private final ISysRoleService roleService;
private final Validator validator; private final Validator validator;
private final AuthUtil authUtil;
private final ISysUserService userService;
public SysRoleHandler(ISysRoleService roleService, Validator validator, AuthUtil authUtil, ISysUserService userService) { public SysRoleHandler(ISysRoleService roleService, Validator validator) {
this.roleService = roleService; this.roleService = roleService;
this.validator = validator; this.validator = validator;
this.authUtil = authUtil;
this.userService = userService;
} }
@Operation(summary = "获取所有角色", description = "获取系统中所有角色列表") @Operation(summary = "获取所有角色", description = "获取系统中所有角色列表")
@@ -123,39 +115,30 @@ public class SysRoleHandler {
}); });
} }
@Operation(summary = "更新角色", description = "更新角色信息,需验证管理员密码,超级管理员角色不可被编辑") @Operation(summary = "更新角色", description = "更新角色信息")
@OperationLog(operation = "更新角色", module = "角色管理") @OperationLog(operation = "更新角色", module = "角色管理")
public Mono<ServerResponse> updateRole(ServerRequest request) { public Mono<ServerResponse> updateRole(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(RoleUpdateRequest.class)
return verifyAdminPassword(request) .map(req -> UpdateRoleCommand.of(
.flatMap(valid -> { id,
if (!valid) return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误"); req.getRoleName(),
if (BUILTIN_ROLE_ID.equals(id)) return ServerResponse.badRequest().bodyValue("超级管理员角色不可编辑"); req.getRoleKey(),
return request.bodyToMono(RoleUpdateRequest.class) req.getRoleSort(),
.map(req -> UpdateRoleCommand.of( req.getStatus()
id, req.getRoleName(), req.getRoleKey(), ))
req.getRoleSort(), req.getStatus() .flatMap(roleService::updateRole)
)) .flatMap(updatedRole -> ServerResponse.ok().bodyValue(updatedRole))
.flatMap(roleService::updateRole) .switchIfEmpty(ServerResponse.notFound().build());
.flatMap(updatedRole -> ServerResponse.ok().bodyValue(updatedRole))
.switchIfEmpty(ServerResponse.notFound().build());
});
} }
@Operation(summary = "删除角色", description = "逻辑删除角色,需验证管理员密码,超级管理员角色不可被删除") @Operation(summary = "删除角色", description = "逻辑删除角色")
@OperationLog(operation = "删除角色", module = "角色管理") @OperationLog(operation = "删除角色", module = "角色管理")
public Mono<ServerResponse> deleteRole(ServerRequest request) { public Mono<ServerResponse> deleteRole(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return roleService.logicalDeleteRole(id)
return verifyAdminPassword(request) .flatMap(role -> ServerResponse.ok().bodyValue(role))
.flatMap(valid -> { .switchIfEmpty(ServerResponse.notFound().build());
if (!valid) return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
if (BUILTIN_ROLE_ID.equals(id)) return ServerResponse.badRequest().bodyValue("超级管理员角色不可删除");
return roleService.logicalDeleteRole(id)
.flatMap(role -> ServerResponse.ok().bodyValue(role))
.switchIfEmpty(ServerResponse.notFound().build());
});
} }
@Operation(summary = "恢复角色", description = "恢复被逻辑删除的角色") @Operation(summary = "恢复角色", description = "恢复被逻辑删除的角色")
@@ -165,13 +148,4 @@ public class SysRoleHandler {
.flatMap(role -> ServerResponse.ok().bodyValue(role)) .flatMap(role -> ServerResponse.ok().bodyValue(role))
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
private Mono<Boolean> verifyAdminPassword(ServerRequest request) {
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return Mono.just(false);
}
Long adminId = authUtil.getMemberIdOrThrow(request);
return userService.verifyPassword(adminId, adminPassword);
}
} }
@@ -2,7 +2,6 @@ package cn.novalon.gym.manage.sys.handler.user;
import cn.novalon.gym.manage.sys.core.domain.SysUser; import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.sys.dto.request.AssignRolesRequest; import cn.novalon.gym.manage.sys.dto.request.AssignRolesRequest;
import cn.novalon.gym.manage.sys.dto.request.PasswordChangeRequest; import cn.novalon.gym.manage.sys.dto.request.PasswordChangeRequest;
@@ -43,12 +42,10 @@ public class SysUserHandler {
private static final Logger logger = LoggerFactory.getLogger(SysUserHandler.class); private static final Logger logger = LoggerFactory.getLogger(SysUserHandler.class);
private final ISysUserService userService; private final ISysUserService userService;
private final Validator validator; private final Validator validator;
private final AuthUtil authUtil;
public SysUserHandler(ISysUserService userService, Validator validator, AuthUtil authUtil) { public SysUserHandler(ISysUserService userService, Validator validator) {
this.userService = userService; this.userService = userService;
this.validator = validator; this.validator = validator;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有用户", description = "获取系统中所有用户列表") @Operation(summary = "获取所有用户", description = "获取系统中所有用户列表")
@@ -155,63 +152,39 @@ public class SysUserHandler {
}); });
} }
@Operation(summary = "更新用户", description = "更新用户信息,需验证管理员密码,超级管理员不可编辑") @Operation(summary = "更新用户", description = "更新用户信息")
@OperationLog(operation = "更新用户", module = "用户管理") @OperationLog(operation = "更新用户", module = "用户管理")
public Mono<ServerResponse> updateUser(ServerRequest request) { public Mono<ServerResponse> updateUser(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(UserUpdateRequest.class)
return verifyAdminPassword(request) .map(req -> {
.flatMap(valid -> { boolean clearRole = Boolean.TRUE.equals(req.getClearRole()) ||
if (!valid) { (req.getRoleId() == null && req.getClearRole() != null);
return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误"); return UpdateUserCommand.of(
} id,
return userService.findById(id) null,
.flatMap(user -> { null,
if ("admin".equals(user.getUsername())) { req.getEmail(),
return Mono.<ServerResponse>error(new RuntimeException("超级管理员不可编辑")); req.getRoleId(),
} req.getStatus(),
return request.bodyToMono(UserUpdateRequest.class) clearRole
.map(req -> { );
boolean clearRole = Boolean.TRUE.equals(req.getClearRole()) || })
(req.getRoleId() == null && req.getClearRole() != null); .flatMap(userService::updateUser)
return UpdateUserCommand.of( .flatMap(user -> ServerResponse.ok().bodyValue(user))
id, null, null, req.getEmail(), .switchIfEmpty(ServerResponse.notFound().build());
req.getRoleId(), req.getStatus(), clearRole
);
})
.flatMap(userService::updateUser)
.flatMap(updated -> ServerResponse.ok().bodyValue(updated));
})
.switchIfEmpty(ServerResponse.notFound().build());
});
} }
@Operation(summary = "删除用户", description = "物理删除用户,需验证管理员密码,超级管理员不可删除") @Operation(summary = "删除用户", description = "物理删除用户")
@OperationLog(operation = "删除用户", module = "用户管理") @OperationLog(operation = "删除用户", module = "用户管理")
public Mono<ServerResponse> deleteUser(ServerRequest request) { public Mono<ServerResponse> deleteUser(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return userService.findById(id)
return verifyAdminPassword(request) .flatMap(user -> userService.deleteUser(id)
.flatMap(valid -> { .then(ServerResponse.noContent().build()))
if (!valid) { .switchIfEmpty(Mono.error(new RuntimeException("User not found")))
return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误");
}
return userService.findById(id)
.flatMap(user -> {
if ("admin".equals(user.getUsername())) {
return Mono.<ServerResponse>error(new RuntimeException("超级管理员不可删除"));
}
return userService.deleteUser(id)
.then(ServerResponse.noContent().build());
})
.switchIfEmpty(Mono.error(new RuntimeException("User not found")));
})
.onErrorResume(RuntimeException.class, ex -> { .onErrorResume(RuntimeException.class, ex -> {
String msg = ex.getMessage(); if (ex.getMessage().contains("not found")) {
if ("超级管理员不可删除".equals(msg) || "超级管理员不可编辑".equals(msg)) {
return ServerResponse.badRequest().bodyValue(msg);
}
if ("User not found".equals(msg)) {
return ServerResponse.notFound().build(); return ServerResponse.notFound().build();
} }
return Mono.error(ex); return Mono.error(ex);
@@ -285,37 +258,16 @@ public class SysUserHandler {
.flatMap(exists -> ServerResponse.ok().bodyValue(exists)); .flatMap(exists -> ServerResponse.ok().bodyValue(exists));
} }
@Operation(summary = "为用户分配角色", description = "为指定用户分配角色列表,需验证管理员密码,超级管理员不可分配") @Operation(summary = "为用户分配角色", description = "为指定用户分配角色列表")
@OperationLog(operation = "分配角色", module = "用户管理") @OperationLog(operation = "分配角色", module = "用户管理")
public Mono<ServerResponse> assignRoles(ServerRequest request) { public Mono<ServerResponse> assignRoles(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(AssignRolesRequest.class)
return verifyAdminPassword(request) .flatMap(req -> userService.assignRolesToUser(id, req.getRoleIdsAsLong()))
.flatMap(valid -> { .then(ServerResponse.ok().build())
if (!valid) { .onErrorResume(error -> {
return ServerResponse.badRequest().bodyValue("管理员密码不能为空或错误"); logger.error("分配角色失败", error);
} return ServerResponse.status(500).bodyValue("分配角色失败: " + error.getMessage());
return userService.findById(id)
.flatMap(user -> {
if ("admin".equals(user.getUsername())) {
return Mono.<ServerResponse>error(new RuntimeException("超级管理员不可被分配角色"));
}
return request.bodyToMono(AssignRolesRequest.class)
.flatMap(req -> userService.assignRolesToUser(id, req.getRoleIdsAsLong()))
.then(ServerResponse.ok().build());
})
.switchIfEmpty(Mono.error(new RuntimeException("User not found")));
})
.onErrorResume(RuntimeException.class, ex -> {
String msg = ex.getMessage();
if ("超级管理员不可被分配角色".equals(msg)) {
return ServerResponse.badRequest().bodyValue(msg);
}
if ("User not found".equals(msg)) {
return ServerResponse.notFound().build();
}
logger.error("分配角色失败", ex);
return ServerResponse.status(500).bodyValue("分配角色失败: " + msg);
}); });
} }
@@ -325,16 +277,4 @@ public class SysUserHandler {
return ServerResponse.ok() return ServerResponse.ok()
.body(userService.getUserRoles(id), cn.novalon.gym.manage.sys.core.domain.SysRole.class); .body(userService.getUserRoles(id), cn.novalon.gym.manage.sys.core.domain.SysRole.class);
} }
/**
* 验证管理员密码
*/
private Mono<Boolean> verifyAdminPassword(ServerRequest request) {
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return Mono.just(false);
}
Long adminId = authUtil.getMemberIdOrThrow(request);
return userService.verifyPassword(adminId, adminPassword);
}
} }
@@ -8,7 +8,7 @@ class AuthResponseTest {
@Test @Test
void testConstructorWithParameters() { void testConstructorWithParameters() {
AuthResponse response = new AuthResponse("test-token", 1L, "testuser", null, null); AuthResponse response = new AuthResponse("test-token", 1L, "testuser");
assertEquals("test-token", response.getToken()); assertEquals("test-token", response.getToken());
assertEquals(1L, response.getUserId()); assertEquals(1L, response.getUserId());
@@ -63,7 +63,7 @@ class AuthResponseTest {
@Test @Test
void testConstructorWithNullValues() { void testConstructorWithNullValues() {
AuthResponse response = new AuthResponse(null, null, null, null, null); AuthResponse response = new AuthResponse(null, null, null);
assertNull(response.getToken()); assertNull(response.getToken());
assertNull(response.getUserId()); assertNull(response.getUserId());
@@ -72,7 +72,7 @@ class AuthResponseTest {
@Test @Test
void testConstructorWithEmptyStrings() { void testConstructorWithEmptyStrings() {
AuthResponse response = new AuthResponse("", 1L, "", null, null); AuthResponse response = new AuthResponse("", 1L, "");
assertEquals("", response.getToken()); assertEquals("", response.getToken());
assertEquals(1L, response.getUserId()); assertEquals(1L, response.getUserId());
@@ -164,7 +164,7 @@ class AuthResponseTest {
@Test @Test
void testConstructorWithZeroUserId() { void testConstructorWithZeroUserId() {
AuthResponse response = new AuthResponse("token", 0L, "user", null, null); AuthResponse response = new AuthResponse("token", 0L, "user");
assertEquals("token", response.getToken()); assertEquals("token", response.getToken());
assertEquals(0L, response.getUserId()); assertEquals(0L, response.getUserId());
@@ -6,11 +6,9 @@ import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import cn.novalon.gym.manage.sys.core.domain.SysUser; import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.SysRole; import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.domain.SysLoginLog; import cn.novalon.gym.manage.sys.core.domain.SysLoginLog;
import cn.novalon.gym.manage.sys.core.domain.SysPermission;
import cn.novalon.gym.manage.sys.util.TestDataFactory; import cn.novalon.gym.manage.sys.util.TestDataFactory;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.core.service.ISysLoginLogService; import cn.novalon.gym.manage.sys.core.service.ISysLoginLogService;
import cn.novalon.gym.manage.sys.core.service.ISysPermissionService;
import cn.novalon.gym.manage.sys.util.UserAgentParser; import cn.novalon.gym.manage.sys.util.UserAgentParser;
import cn.novalon.gym.manage.sys.util.IpLocationParser; import cn.novalon.gym.manage.sys.util.IpLocationParser;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -49,9 +47,6 @@ class SysAuthHandlerTest {
@Mock @Mock
private ISysLoginLogService loginLogService; private ISysLoginLogService loginLogService;
@Mock
private ISysPermissionService permissionService;
@Mock @Mock
private UserAgentParser userAgentParser; private UserAgentParser userAgentParser;
@@ -64,7 +59,7 @@ class SysAuthHandlerTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
authHandler = new SysAuthHandler(userService, passwordEncoder, jwtTokenProvider, loginLogService, authHandler = new SysAuthHandler(userService, passwordEncoder, jwtTokenProvider, loginLogService,
permissionService, userAgentParser, ipLocationParser); userAgentParser, ipLocationParser);
testUser = TestDataFactory.createTestUser(); testUser = TestDataFactory.createTestUser();
} }
@@ -93,10 +88,6 @@ class SysAuthHandlerTest {
when(userService.getUserRoles(1L)).thenReturn(Flux.just(mockRole)); when(userService.getUserRoles(1L)).thenReturn(Flux.just(mockRole));
when(loginLogService.save(any())).thenReturn(Mono.just(new SysLoginLog())); when(loginLogService.save(any())).thenReturn(Mono.just(new SysLoginLog()));
SysPermission mockPermission = new SysPermission();
mockPermission.setPermissionCode("system:user:view");
when(permissionService.findByRoleIds(anyList())).thenReturn(Flux.just(mockPermission));
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.body(Mono.just(loginRequest)); .body(Mono.just(loginRequest));
Mono<ServerResponse> response = authHandler.login(request); Mono<ServerResponse> response = authHandler.login(request);
@@ -2,8 +2,6 @@ package cn.novalon.gym.manage.sys.handler.role;
import cn.novalon.gym.manage.sys.core.domain.SysRole; import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.service.ISysRoleService; import cn.novalon.gym.manage.sys.core.service.ISysRoleService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.dto.request.RoleCreateRequest; import cn.novalon.gym.manage.sys.dto.request.RoleCreateRequest;
import cn.novalon.gym.manage.sys.dto.request.RoleUpdateRequest; import cn.novalon.gym.manage.sys.dto.request.RoleUpdateRequest;
import cn.novalon.gym.manage.sys.core.command.CreateRoleCommand; import cn.novalon.gym.manage.sys.core.command.CreateRoleCommand;
@@ -37,18 +35,12 @@ class SysRoleHandlerTest {
@Mock @Mock
private Validator validator; private Validator validator;
@Mock
private AuthUtil authUtil;
@Mock
private ISysUserService userService;
private SysRoleHandler roleHandler; private SysRoleHandler roleHandler;
private SysRole testRole; private SysRole testRole;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
roleHandler = new SysRoleHandler(roleService, validator, authUtil, userService); roleHandler = new SysRoleHandler(roleService, validator);
testRole = new SysRole(); testRole = new SysRole();
testRole.setId(1L); testRole.setId(1L);
@@ -259,13 +251,10 @@ class SysRoleHandlerTest {
updateRequest.setRoleSort(3); updateRequest.setRoleSort(3);
updateRequest.setStatus(0); updateRequest.setStatus(0);
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L);
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(roleService.updateRole(any(UpdateRoleCommand.class))).thenReturn(Mono.just(testRole)); when(roleService.updateRole(any(UpdateRoleCommand.class))).thenReturn(Mono.just(testRole));
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "2") .pathVariable("id", "1")
.queryParam("adminPassword", "password123")
.body(Mono.just(updateRequest)); .body(Mono.just(updateRequest));
Mono<ServerResponse> response = roleHandler.updateRole(request); Mono<ServerResponse> response = roleHandler.updateRole(request);
@@ -282,13 +271,10 @@ class SysRoleHandlerTest {
RoleUpdateRequest updateRequest = new RoleUpdateRequest(); RoleUpdateRequest updateRequest = new RoleUpdateRequest();
updateRequest.setRoleName("UPDATED_ROLE"); updateRequest.setRoleName("UPDATED_ROLE");
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L);
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(roleService.updateRole(any(UpdateRoleCommand.class))).thenReturn(Mono.empty()); when(roleService.updateRole(any(UpdateRoleCommand.class))).thenReturn(Mono.empty());
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999") .pathVariable("id", "999")
.queryParam("adminPassword", "password123")
.body(Mono.just(updateRequest)); .body(Mono.just(updateRequest));
Mono<ServerResponse> response = roleHandler.updateRole(request); Mono<ServerResponse> response = roleHandler.updateRole(request);
@@ -302,13 +288,10 @@ class SysRoleHandlerTest {
@Test @Test
void testDeleteRole() { void testDeleteRole() {
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L); when(roleService.logicalDeleteRole(1L)).thenReturn(Mono.just(testRole));
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(roleService.logicalDeleteRole(2L)).thenReturn(Mono.just(testRole));
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "2") .pathVariable("id", "1")
.queryParam("adminPassword", "password123")
.build(); .build();
Mono<ServerResponse> response = roleHandler.deleteRole(request); Mono<ServerResponse> response = roleHandler.deleteRole(request);
@@ -317,18 +300,15 @@ class SysRoleHandlerTest {
serverResponse.statusCode() == HttpStatus.OK) serverResponse.statusCode() == HttpStatus.OK)
.verifyComplete(); .verifyComplete();
verify(roleService).logicalDeleteRole(2L); verify(roleService).logicalDeleteRole(1L);
} }
@Test @Test
void testDeleteRole_NotFound() { void testDeleteRole_NotFound() {
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L);
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(roleService.logicalDeleteRole(999L)).thenReturn(Mono.empty()); when(roleService.logicalDeleteRole(999L)).thenReturn(Mono.empty());
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999") .pathVariable("id", "999")
.queryParam("adminPassword", "password123")
.build(); .build();
Mono<ServerResponse> response = roleHandler.deleteRole(request); Mono<ServerResponse> response = roleHandler.deleteRole(request);
@@ -2,7 +2,6 @@ package cn.novalon.gym.manage.sys.handler.user;
import cn.novalon.gym.manage.sys.core.domain.SysUser; import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.dto.request.PasswordChangeRequest; import cn.novalon.gym.manage.sys.dto.request.PasswordChangeRequest;
import cn.novalon.gym.manage.sys.dto.request.UserRegisterRequest; import cn.novalon.gym.manage.sys.dto.request.UserRegisterRequest;
import cn.novalon.gym.manage.sys.dto.request.UserUpdateRequest; import cn.novalon.gym.manage.sys.dto.request.UserUpdateRequest;
@@ -43,15 +42,12 @@ class SysUserHandlerTest {
@Mock @Mock
private Validator validator; private Validator validator;
@Mock
private AuthUtil authUtil;
private SysUserHandler userHandler; private SysUserHandler userHandler;
private SysUser testUser; private SysUser testUser;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
userHandler = new SysUserHandler(userService, validator, authUtil); userHandler = new SysUserHandler(userService, validator);
testUser = new SysUser(); testUser = new SysUser();
testUser.setId(1L); testUser.setId(1L);
@@ -195,12 +191,9 @@ class SysUserHandlerTest {
void testDeleteUser() { void testDeleteUser() {
when(userService.findById(1L)).thenReturn(Mono.just(testUser)); when(userService.findById(1L)).thenReturn(Mono.just(testUser));
when(userService.deleteUser(1L)).thenReturn(Mono.empty()); when(userService.deleteUser(1L)).thenReturn(Mono.empty());
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L);
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1") .pathVariable("id", "1")
.queryParam("adminPassword", "password123")
.build(); .build();
Mono<ServerResponse> response = userHandler.deleteUser(request); Mono<ServerResponse> response = userHandler.deleteUser(request);
@@ -386,14 +379,10 @@ class SysUserHandlerTest {
updateRequest.setRoleId(2L); updateRequest.setRoleId(2L);
updateRequest.setStatus(0); updateRequest.setStatus(0);
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L);
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(userService.findById(1L)).thenReturn(Mono.just(testUser));
when(userService.updateUser(any(UpdateUserCommand.class))).thenReturn(Mono.just(testUser)); when(userService.updateUser(any(UpdateUserCommand.class))).thenReturn(Mono.just(testUser));
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1") .pathVariable("id", "1")
.queryParam("adminPassword", "password123")
.body(Mono.just(updateRequest)); .body(Mono.just(updateRequest));
Mono<ServerResponse> response = userHandler.updateUser(request); Mono<ServerResponse> response = userHandler.updateUser(request);
@@ -410,13 +399,10 @@ class SysUserHandlerTest {
UserUpdateRequest updateRequest = new UserUpdateRequest(); UserUpdateRequest updateRequest = new UserUpdateRequest();
updateRequest.setEmail("updated@example.com"); updateRequest.setEmail("updated@example.com");
when(authUtil.getMemberIdOrThrow(any())).thenReturn(1L); when(userService.updateUser(any(UpdateUserCommand.class))).thenReturn(Mono.empty());
when(userService.verifyPassword(1L, "password123")).thenReturn(Mono.just(true));
when(userService.findById(999L)).thenReturn(Mono.empty());
ServerRequest request = MockServerRequest.builder() ServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999") .pathVariable("id", "999")
.queryParam("adminPassword", "password123")
.body(Mono.just(updateRequest)); .body(Mono.just(updateRequest));
Mono<ServerResponse> response = userHandler.updateUser(request); Mono<ServerResponse> response = userHandler.updateUser(request);
@@ -425,7 +411,7 @@ class SysUserHandlerTest {
serverResponse.statusCode() == HttpStatus.NOT_FOUND) serverResponse.statusCode() == HttpStatus.NOT_FOUND)
.verifyComplete(); .verifyComplete();
verify(userService).findById(999L); verify(userService).updateUser(any(UpdateUserCommand.class));
} }
@Test @Test
+5
View File
@@ -213,6 +213,11 @@
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version> <version>${poi.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.21</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://localhost:8084
VITE_APP_TITLE=CUIT Gym 管理系统
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=/api
VITE_APP_TITLE=CUIT Gym 管理系统
-39
View File
@@ -1,39 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
-6
View File
@@ -1,6 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
-6
View File
@@ -1,6 +0,0 @@
{
"recommendations": [
"Vue.volar",
"esbenp.prettier-vscode"
]
}
-42
View File
@@ -1,42 +0,0 @@
# gym-manage-cuit
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CUIT Gym 管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-40
View File
@@ -1,40 +0,0 @@
{
"name": "gym-manage-cuit",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^9.0.2",
"prettier": "3.8.4",
"sass-embedded": "^1.100.0",
"typescript": "~6.0.0",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.3.5"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}

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